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
987589ea
"frontend/src/git@web.lueluesay.top:chenxi/sub2api.git" did not exist on "5443efd7d764bbbba79279df10bce56d0c70ec34"
Commit
987589ea
authored
Feb 21, 2026
by
yangjianbo
Browse files
Merge branch 'test' into release
parents
372e04f6
03f69dd3
Changes
109
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/proxy_handler.go
View file @
987589ea
...
@@ -236,6 +236,24 @@ func (h *ProxyHandler) Test(c *gin.Context) {
...
@@ -236,6 +236,24 @@ func (h *ProxyHandler) Test(c *gin.Context) {
response
.
Success
(
c
,
result
)
response
.
Success
(
c
,
result
)
}
}
// CheckQuality handles checking proxy quality across common AI targets.
// POST /api/v1/admin/proxies/:id/quality-check
func
(
h
*
ProxyHandler
)
CheckQuality
(
c
*
gin
.
Context
)
{
proxyID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid proxy ID"
)
return
}
result
,
err
:=
h
.
adminService
.
CheckProxyQuality
(
c
.
Request
.
Context
(),
proxyID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
result
)
}
// GetStats handles getting proxy statistics
// GetStats handles getting proxy statistics
// GET /api/v1/admin/proxies/:id/stats
// GET /api/v1/admin/proxies/:id/stats
func
(
h
*
ProxyHandler
)
GetStats
(
c
*
gin
.
Context
)
{
func
(
h
*
ProxyHandler
)
GetStats
(
c
*
gin
.
Context
)
{
...
...
backend/internal/handler/dto/mappers.go
View file @
987589ea
...
@@ -214,6 +214,13 @@ func AccountFromServiceShallow(a *service.Account) *Account {
...
@@ -214,6 +214,13 @@ func AccountFromServiceShallow(a *service.Account) *Account {
enabled
:=
true
enabled
:=
true
out
.
EnableSessionIDMasking
=
&
enabled
out
.
EnableSessionIDMasking
=
&
enabled
}
}
// 缓存 TTL 强制替换
if
a
.
IsCacheTTLOverrideEnabled
()
{
enabled
:=
true
out
.
CacheTTLOverrideEnabled
=
&
enabled
target
:=
a
.
GetCacheTTLOverrideTarget
()
out
.
CacheTTLOverrideTarget
=
&
target
}
}
}
return
out
return
out
...
@@ -296,6 +303,11 @@ func ProxyWithAccountCountFromService(p *service.ProxyWithAccountCount) *ProxyWi
...
@@ -296,6 +303,11 @@ func ProxyWithAccountCountFromService(p *service.ProxyWithAccountCount) *ProxyWi
CountryCode
:
p
.
CountryCode
,
CountryCode
:
p
.
CountryCode
,
Region
:
p
.
Region
,
Region
:
p
.
Region
,
City
:
p
.
City
,
City
:
p
.
City
,
QualityStatus
:
p
.
QualityStatus
,
QualityScore
:
p
.
QualityScore
,
QualityGrade
:
p
.
QualityGrade
,
QualitySummary
:
p
.
QualitySummary
,
QualityChecked
:
p
.
QualityChecked
,
}
}
}
}
...
@@ -402,6 +414,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
...
@@ -402,6 +414,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
ImageSize
:
l
.
ImageSize
,
ImageSize
:
l
.
ImageSize
,
MediaType
:
l
.
MediaType
,
MediaType
:
l
.
MediaType
,
UserAgent
:
l
.
UserAgent
,
UserAgent
:
l
.
UserAgent
,
CacheTTLOverridden
:
l
.
CacheTTLOverridden
,
CreatedAt
:
l
.
CreatedAt
,
CreatedAt
:
l
.
CreatedAt
,
User
:
UserFromServiceShallow
(
l
.
User
),
User
:
UserFromServiceShallow
(
l
.
User
),
APIKey
:
APIKeyFromService
(
l
.
APIKey
),
APIKey
:
APIKeyFromService
(
l
.
APIKey
),
...
...
backend/internal/handler/dto/types.go
View file @
987589ea
...
@@ -156,6 +156,11 @@ type Account struct {
...
@@ -156,6 +156,11 @@ type Account struct {
// 从 extra 字段提取,方便前端显示和编辑
// 从 extra 字段提取,方便前端显示和编辑
EnableSessionIDMasking
*
bool
`json:"session_id_masking_enabled,omitempty"`
EnableSessionIDMasking
*
bool
`json:"session_id_masking_enabled,omitempty"`
// 缓存 TTL 强制替换(仅 Anthropic OAuth/SetupToken 账号有效)
// 启用后将所有 cache creation tokens 归入指定的 TTL 类型计费
CacheTTLOverrideEnabled
*
bool
`json:"cache_ttl_override_enabled,omitempty"`
CacheTTLOverrideTarget
*
string
`json:"cache_ttl_override_target,omitempty"`
Proxy
*
Proxy
`json:"proxy,omitempty"`
Proxy
*
Proxy
`json:"proxy,omitempty"`
AccountGroups
[]
AccountGroup
`json:"account_groups,omitempty"`
AccountGroups
[]
AccountGroup
`json:"account_groups,omitempty"`
...
@@ -197,6 +202,11 @@ type ProxyWithAccountCount struct {
...
@@ -197,6 +202,11 @@ type ProxyWithAccountCount struct {
CountryCode
string
`json:"country_code,omitempty"`
CountryCode
string
`json:"country_code,omitempty"`
Region
string
`json:"region,omitempty"`
Region
string
`json:"region,omitempty"`
City
string
`json:"city,omitempty"`
City
string
`json:"city,omitempty"`
QualityStatus
string
`json:"quality_status,omitempty"`
QualityScore
*
int
`json:"quality_score,omitempty"`
QualityGrade
string
`json:"quality_grade,omitempty"`
QualitySummary
string
`json:"quality_summary,omitempty"`
QualityChecked
*
int64
`json:"quality_checked,omitempty"`
}
}
type
ProxyAccountSummary
struct
{
type
ProxyAccountSummary
struct
{
...
@@ -280,6 +290,9 @@ type UsageLog struct {
...
@@ -280,6 +290,9 @@ type UsageLog struct {
// User-Agent
// User-Agent
UserAgent
*
string
`json:"user_agent"`
UserAgent
*
string
`json:"user_agent"`
// Cache TTL Override 标记
CacheTTLOverridden
bool
`json:"cache_ttl_overridden"`
CreatedAt
time
.
Time
`json:"created_at"`
CreatedAt
time
.
Time
`json:"created_at"`
User
*
User
`json:"user,omitempty"`
User
*
User
`json:"user,omitempty"`
...
...
backend/internal/handler/sora_gateway_handler.go
View file @
987589ea
...
@@ -4,6 +4,7 @@ import (
...
@@ -4,6 +4,7 @@ import (
"context"
"context"
"crypto/sha256"
"crypto/sha256"
"encoding/hex"
"encoding/hex"
"encoding/json"
"errors"
"errors"
"fmt"
"fmt"
"io"
"io"
...
@@ -20,6 +21,7 @@ import (
...
@@ -20,6 +21,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/util/soraerror"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/gjson"
...
@@ -35,6 +37,7 @@ type SoraGatewayHandler struct {
...
@@ -35,6 +37,7 @@ type SoraGatewayHandler struct {
concurrencyHelper
*
ConcurrencyHelper
concurrencyHelper
*
ConcurrencyHelper
maxAccountSwitches
int
maxAccountSwitches
int
streamMode
string
streamMode
string
soraTLSEnabled
bool
soraMediaSigningKey
string
soraMediaSigningKey
string
soraMediaRoot
string
soraMediaRoot
string
}
}
...
@@ -50,6 +53,7 @@ func NewSoraGatewayHandler(
...
@@ -50,6 +53,7 @@ func NewSoraGatewayHandler(
pingInterval
:=
time
.
Duration
(
0
)
pingInterval
:=
time
.
Duration
(
0
)
maxAccountSwitches
:=
3
maxAccountSwitches
:=
3
streamMode
:=
"force"
streamMode
:=
"force"
soraTLSEnabled
:=
true
signKey
:=
""
signKey
:=
""
mediaRoot
:=
"/app/data/sora"
mediaRoot
:=
"/app/data/sora"
if
cfg
!=
nil
{
if
cfg
!=
nil
{
...
@@ -60,6 +64,7 @@ func NewSoraGatewayHandler(
...
@@ -60,6 +64,7 @@ func NewSoraGatewayHandler(
if
mode
:=
strings
.
TrimSpace
(
cfg
.
Gateway
.
SoraStreamMode
);
mode
!=
""
{
if
mode
:=
strings
.
TrimSpace
(
cfg
.
Gateway
.
SoraStreamMode
);
mode
!=
""
{
streamMode
=
mode
streamMode
=
mode
}
}
soraTLSEnabled
=
!
cfg
.
Sora
.
Client
.
DisableTLSFingerprint
signKey
=
strings
.
TrimSpace
(
cfg
.
Gateway
.
SoraMediaSigningKey
)
signKey
=
strings
.
TrimSpace
(
cfg
.
Gateway
.
SoraMediaSigningKey
)
if
root
:=
strings
.
TrimSpace
(
cfg
.
Sora
.
Storage
.
LocalPath
);
root
!=
""
{
if
root
:=
strings
.
TrimSpace
(
cfg
.
Sora
.
Storage
.
LocalPath
);
root
!=
""
{
mediaRoot
=
root
mediaRoot
=
root
...
@@ -72,6 +77,7 @@ func NewSoraGatewayHandler(
...
@@ -72,6 +77,7 @@ func NewSoraGatewayHandler(
concurrencyHelper
:
NewConcurrencyHelper
(
concurrencyService
,
SSEPingFormatComment
,
pingInterval
),
concurrencyHelper
:
NewConcurrencyHelper
(
concurrencyService
,
SSEPingFormatComment
,
pingInterval
),
maxAccountSwitches
:
maxAccountSwitches
,
maxAccountSwitches
:
maxAccountSwitches
,
streamMode
:
strings
.
ToLower
(
streamMode
),
streamMode
:
strings
.
ToLower
(
streamMode
),
soraTLSEnabled
:
soraTLSEnabled
,
soraMediaSigningKey
:
signKey
,
soraMediaSigningKey
:
signKey
,
soraMediaRoot
:
mediaRoot
,
soraMediaRoot
:
mediaRoot
,
}
}
...
@@ -212,6 +218,8 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) {
...
@@ -212,6 +218,8 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) {
switchCount
:=
0
switchCount
:=
0
failedAccountIDs
:=
make
(
map
[
int64
]
struct
{})
failedAccountIDs
:=
make
(
map
[
int64
]
struct
{})
lastFailoverStatus
:=
0
lastFailoverStatus
:=
0
var
lastFailoverBody
[]
byte
var
lastFailoverHeaders
http
.
Header
for
{
for
{
selection
,
err
:=
h
.
gatewayService
.
SelectAccountWithLoadAwareness
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionHash
,
reqModel
,
failedAccountIDs
,
""
)
selection
,
err
:=
h
.
gatewayService
.
SelectAccountWithLoadAwareness
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionHash
,
reqModel
,
failedAccountIDs
,
""
)
...
@@ -224,11 +232,31 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) {
...
@@ -224,11 +232,31 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) {
h
.
handleStreamingAwareError
(
c
,
http
.
StatusServiceUnavailable
,
"api_error"
,
"No available accounts: "
+
err
.
Error
(),
streamStarted
)
h
.
handleStreamingAwareError
(
c
,
http
.
StatusServiceUnavailable
,
"api_error"
,
"No available accounts: "
+
err
.
Error
(),
streamStarted
)
return
return
}
}
h
.
handleFailoverExhausted
(
c
,
lastFailoverStatus
,
streamStarted
)
rayID
,
mitigated
,
contentType
:=
extractSoraFailoverHeaderInsights
(
lastFailoverHeaders
,
lastFailoverBody
)
fields
:=
[]
zap
.
Field
{
zap
.
Int
(
"last_upstream_status"
,
lastFailoverStatus
),
}
if
rayID
!=
""
{
fields
=
append
(
fields
,
zap
.
String
(
"last_upstream_cf_ray"
,
rayID
))
}
if
mitigated
!=
""
{
fields
=
append
(
fields
,
zap
.
String
(
"last_upstream_cf_mitigated"
,
mitigated
))
}
if
contentType
!=
""
{
fields
=
append
(
fields
,
zap
.
String
(
"last_upstream_content_type"
,
contentType
))
}
reqLog
.
Warn
(
"sora.failover_exhausted_no_available_accounts"
,
fields
...
)
h
.
handleFailoverExhausted
(
c
,
lastFailoverStatus
,
lastFailoverHeaders
,
lastFailoverBody
,
streamStarted
)
return
return
}
}
account
:=
selection
.
Account
account
:=
selection
.
Account
setOpsSelectedAccount
(
c
,
account
.
ID
,
account
.
Platform
)
setOpsSelectedAccount
(
c
,
account
.
ID
,
account
.
Platform
)
proxyBound
:=
account
.
ProxyID
!=
nil
proxyID
:=
int64
(
0
)
if
account
.
ProxyID
!=
nil
{
proxyID
=
*
account
.
ProxyID
}
tlsFingerprintEnabled
:=
h
.
soraTLSEnabled
accountReleaseFunc
:=
selection
.
ReleaseFunc
accountReleaseFunc
:=
selection
.
ReleaseFunc
if
!
selection
.
Acquired
{
if
!
selection
.
Acquired
{
...
@@ -239,10 +267,19 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) {
...
@@ -239,10 +267,19 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) {
accountWaitCounted
:=
false
accountWaitCounted
:=
false
canWait
,
err
:=
h
.
concurrencyHelper
.
IncrementAccountWaitCount
(
c
.
Request
.
Context
(),
account
.
ID
,
selection
.
WaitPlan
.
MaxWaiting
)
canWait
,
err
:=
h
.
concurrencyHelper
.
IncrementAccountWaitCount
(
c
.
Request
.
Context
(),
account
.
ID
,
selection
.
WaitPlan
.
MaxWaiting
)
if
err
!=
nil
{
if
err
!=
nil
{
reqLog
.
Warn
(
"sora.account_wait_counter_increment_failed"
,
zap
.
Int64
(
"account_id"
,
account
.
ID
),
zap
.
Error
(
err
))
reqLog
.
Warn
(
"sora.account_wait_counter_increment_failed"
,
zap
.
Int64
(
"account_id"
,
account
.
ID
),
zap
.
Int64
(
"proxy_id"
,
proxyID
),
zap
.
Bool
(
"proxy_bound"
,
proxyBound
),
zap
.
Bool
(
"tls_fingerprint_enabled"
,
tlsFingerprintEnabled
),
zap
.
Error
(
err
),
)
}
else
if
!
canWait
{
}
else
if
!
canWait
{
reqLog
.
Info
(
"sora.account_wait_queue_full"
,
reqLog
.
Info
(
"sora.account_wait_queue_full"
,
zap
.
Int64
(
"account_id"
,
account
.
ID
),
zap
.
Int64
(
"account_id"
,
account
.
ID
),
zap
.
Int64
(
"proxy_id"
,
proxyID
),
zap
.
Bool
(
"proxy_bound"
,
proxyBound
),
zap
.
Bool
(
"tls_fingerprint_enabled"
,
tlsFingerprintEnabled
),
zap
.
Int
(
"max_waiting"
,
selection
.
WaitPlan
.
MaxWaiting
),
zap
.
Int
(
"max_waiting"
,
selection
.
WaitPlan
.
MaxWaiting
),
)
)
h
.
handleStreamingAwareError
(
c
,
http
.
StatusTooManyRequests
,
"rate_limit_error"
,
"Too many pending requests, please retry later"
,
streamStarted
)
h
.
handleStreamingAwareError
(
c
,
http
.
StatusTooManyRequests
,
"rate_limit_error"
,
"Too many pending requests, please retry later"
,
streamStarted
)
...
@@ -266,7 +303,13 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) {
...
@@ -266,7 +303,13 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) {
&
streamStarted
,
&
streamStarted
,
)
)
if
err
!=
nil
{
if
err
!=
nil
{
reqLog
.
Warn
(
"sora.account_slot_acquire_failed"
,
zap
.
Int64
(
"account_id"
,
account
.
ID
),
zap
.
Error
(
err
))
reqLog
.
Warn
(
"sora.account_slot_acquire_failed"
,
zap
.
Int64
(
"account_id"
,
account
.
ID
),
zap
.
Int64
(
"proxy_id"
,
proxyID
),
zap
.
Bool
(
"proxy_bound"
,
proxyBound
),
zap
.
Bool
(
"tls_fingerprint_enabled"
,
tlsFingerprintEnabled
),
zap
.
Error
(
err
),
)
h
.
handleConcurrencyError
(
c
,
err
,
"account"
,
streamStarted
)
h
.
handleConcurrencyError
(
c
,
err
,
"account"
,
streamStarted
)
return
return
}
}
...
@@ -287,20 +330,67 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) {
...
@@ -287,20 +330,67 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) {
failedAccountIDs
[
account
.
ID
]
=
struct
{}{}
failedAccountIDs
[
account
.
ID
]
=
struct
{}{}
if
switchCount
>=
maxAccountSwitches
{
if
switchCount
>=
maxAccountSwitches
{
lastFailoverStatus
=
failoverErr
.
StatusCode
lastFailoverStatus
=
failoverErr
.
StatusCode
h
.
handleFailoverExhausted
(
c
,
lastFailoverStatus
,
streamStarted
)
lastFailoverHeaders
=
cloneHTTPHeaders
(
failoverErr
.
ResponseHeaders
)
lastFailoverBody
=
failoverErr
.
ResponseBody
rayID
,
mitigated
,
contentType
:=
extractSoraFailoverHeaderInsights
(
lastFailoverHeaders
,
lastFailoverBody
)
fields
:=
[]
zap
.
Field
{
zap
.
Int64
(
"account_id"
,
account
.
ID
),
zap
.
Int64
(
"proxy_id"
,
proxyID
),
zap
.
Bool
(
"proxy_bound"
,
proxyBound
),
zap
.
Bool
(
"tls_fingerprint_enabled"
,
tlsFingerprintEnabled
),
zap
.
Int
(
"upstream_status"
,
failoverErr
.
StatusCode
),
zap
.
Int
(
"switch_count"
,
switchCount
),
zap
.
Int
(
"max_switches"
,
maxAccountSwitches
),
}
if
rayID
!=
""
{
fields
=
append
(
fields
,
zap
.
String
(
"upstream_cf_ray"
,
rayID
))
}
if
mitigated
!=
""
{
fields
=
append
(
fields
,
zap
.
String
(
"upstream_cf_mitigated"
,
mitigated
))
}
if
contentType
!=
""
{
fields
=
append
(
fields
,
zap
.
String
(
"upstream_content_type"
,
contentType
))
}
reqLog
.
Warn
(
"sora.upstream_failover_exhausted"
,
fields
...
)
h
.
handleFailoverExhausted
(
c
,
lastFailoverStatus
,
lastFailoverHeaders
,
lastFailoverBody
,
streamStarted
)
return
return
}
}
lastFailoverStatus
=
failoverErr
.
StatusCode
lastFailoverStatus
=
failoverErr
.
StatusCode
lastFailoverHeaders
=
cloneHTTPHeaders
(
failoverErr
.
ResponseHeaders
)
lastFailoverBody
=
failoverErr
.
ResponseBody
switchCount
++
switchCount
++
reqLog
.
Warn
(
"sora.upstream_failover_switching"
,
upstreamErrCode
,
upstreamErrMsg
:=
extractUpstreamErrorCodeAndMessage
(
lastFailoverBody
)
rayID
,
mitigated
,
contentType
:=
extractSoraFailoverHeaderInsights
(
lastFailoverHeaders
,
lastFailoverBody
)
fields
:=
[]
zap
.
Field
{
zap
.
Int64
(
"account_id"
,
account
.
ID
),
zap
.
Int64
(
"account_id"
,
account
.
ID
),
zap
.
Int64
(
"proxy_id"
,
proxyID
),
zap
.
Bool
(
"proxy_bound"
,
proxyBound
),
zap
.
Bool
(
"tls_fingerprint_enabled"
,
tlsFingerprintEnabled
),
zap
.
Int
(
"upstream_status"
,
failoverErr
.
StatusCode
),
zap
.
Int
(
"upstream_status"
,
failoverErr
.
StatusCode
),
zap
.
String
(
"upstream_error_code"
,
upstreamErrCode
),
zap
.
String
(
"upstream_error_message"
,
upstreamErrMsg
),
zap
.
Int
(
"switch_count"
,
switchCount
),
zap
.
Int
(
"switch_count"
,
switchCount
),
zap
.
Int
(
"max_switches"
,
maxAccountSwitches
),
zap
.
Int
(
"max_switches"
,
maxAccountSwitches
),
)
}
if
rayID
!=
""
{
fields
=
append
(
fields
,
zap
.
String
(
"upstream_cf_ray"
,
rayID
))
}
if
mitigated
!=
""
{
fields
=
append
(
fields
,
zap
.
String
(
"upstream_cf_mitigated"
,
mitigated
))
}
if
contentType
!=
""
{
fields
=
append
(
fields
,
zap
.
String
(
"upstream_content_type"
,
contentType
))
}
reqLog
.
Warn
(
"sora.upstream_failover_switching"
,
fields
...
)
continue
continue
}
}
reqLog
.
Error
(
"sora.forward_failed"
,
zap
.
Int64
(
"account_id"
,
account
.
ID
),
zap
.
Error
(
err
))
reqLog
.
Error
(
"sora.forward_failed"
,
zap
.
Int64
(
"account_id"
,
account
.
ID
),
zap
.
Int64
(
"proxy_id"
,
proxyID
),
zap
.
Bool
(
"proxy_bound"
,
proxyBound
),
zap
.
Bool
(
"tls_fingerprint_enabled"
,
tlsFingerprintEnabled
),
zap
.
Error
(
err
),
)
return
return
}
}
...
@@ -331,6 +421,9 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) {
...
@@ -331,6 +421,9 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) {
}(
result
,
account
,
userAgent
,
clientIP
)
}(
result
,
account
,
userAgent
,
clientIP
)
reqLog
.
Debug
(
"sora.request_completed"
,
reqLog
.
Debug
(
"sora.request_completed"
,
zap
.
Int64
(
"account_id"
,
account
.
ID
),
zap
.
Int64
(
"account_id"
,
account
.
ID
),
zap
.
Int64
(
"proxy_id"
,
proxyID
),
zap
.
Bool
(
"proxy_bound"
,
proxyBound
),
zap
.
Bool
(
"tls_fingerprint_enabled"
,
tlsFingerprintEnabled
),
zap
.
Int
(
"switch_count"
,
switchCount
),
zap
.
Int
(
"switch_count"
,
switchCount
),
)
)
return
return
...
@@ -360,17 +453,41 @@ func (h *SoraGatewayHandler) handleConcurrencyError(c *gin.Context, err error, s
...
@@ -360,17 +453,41 @@ func (h *SoraGatewayHandler) handleConcurrencyError(c *gin.Context, err error, s
fmt
.
Sprintf
(
"Concurrency limit exceeded for %s, please retry later"
,
slotType
),
streamStarted
)
fmt
.
Sprintf
(
"Concurrency limit exceeded for %s, please retry later"
,
slotType
),
streamStarted
)
}
}
func
(
h
*
SoraGatewayHandler
)
handleFailoverExhausted
(
c
*
gin
.
Context
,
statusCode
int
,
streamStarted
bool
)
{
func
(
h
*
SoraGatewayHandler
)
handleFailoverExhausted
(
c
*
gin
.
Context
,
statusCode
int
,
responseHeaders
http
.
Header
,
responseBody
[]
byte
,
streamStarted
bool
)
{
status
,
errType
,
errMsg
:=
h
.
mapUpstreamError
(
statusCode
)
status
,
errType
,
errMsg
:=
h
.
mapUpstreamError
(
statusCode
,
responseHeaders
,
responseBody
)
h
.
handleStreamingAwareError
(
c
,
status
,
errType
,
errMsg
,
streamStarted
)
h
.
handleStreamingAwareError
(
c
,
status
,
errType
,
errMsg
,
streamStarted
)
}
}
func
(
h
*
SoraGatewayHandler
)
mapUpstreamError
(
statusCode
int
)
(
int
,
string
,
string
)
{
func
(
h
*
SoraGatewayHandler
)
mapUpstreamError
(
statusCode
int
,
responseHeaders
http
.
Header
,
responseBody
[]
byte
)
(
int
,
string
,
string
)
{
if
isSoraCloudflareChallengeResponse
(
statusCode
,
responseHeaders
,
responseBody
)
{
baseMsg
:=
fmt
.
Sprintf
(
"Sora request blocked by Cloudflare challenge (HTTP %d). Please switch to a clean proxy/network and retry."
,
statusCode
)
return
http
.
StatusBadGateway
,
"upstream_error"
,
formatSoraCloudflareChallengeMessage
(
baseMsg
,
responseHeaders
,
responseBody
)
}
upstreamCode
,
upstreamMessage
:=
extractUpstreamErrorCodeAndMessage
(
responseBody
)
if
strings
.
EqualFold
(
upstreamCode
,
"cf_shield_429"
)
{
baseMsg
:=
"Sora request blocked by Cloudflare shield (429). Please switch to a clean proxy/network and retry."
return
http
.
StatusTooManyRequests
,
"rate_limit_error"
,
formatSoraCloudflareChallengeMessage
(
baseMsg
,
responseHeaders
,
responseBody
)
}
if
shouldPassthroughSoraUpstreamMessage
(
statusCode
,
upstreamMessage
)
{
switch
statusCode
{
case
401
,
403
,
404
,
500
,
502
,
503
,
504
:
return
http
.
StatusBadGateway
,
"upstream_error"
,
upstreamMessage
case
429
:
return
http
.
StatusTooManyRequests
,
"rate_limit_error"
,
upstreamMessage
}
}
switch
statusCode
{
switch
statusCode
{
case
401
:
case
401
:
return
http
.
StatusBadGateway
,
"upstream_error"
,
"Upstream authentication failed, please contact administrator"
return
http
.
StatusBadGateway
,
"upstream_error"
,
"Upstream authentication failed, please contact administrator"
case
403
:
case
403
:
return
http
.
StatusBadGateway
,
"upstream_error"
,
"Upstream access forbidden, please contact administrator"
return
http
.
StatusBadGateway
,
"upstream_error"
,
"Upstream access forbidden, please contact administrator"
case
404
:
if
strings
.
EqualFold
(
upstreamCode
,
"unsupported_country_code"
)
{
return
http
.
StatusBadGateway
,
"upstream_error"
,
"Upstream region capability unavailable for this account, please contact administrator"
}
return
http
.
StatusBadGateway
,
"upstream_error"
,
"Upstream capability unavailable for this account, please contact administrator"
case
429
:
case
429
:
return
http
.
StatusTooManyRequests
,
"rate_limit_error"
,
"Upstream rate limit exceeded, please retry later"
return
http
.
StatusTooManyRequests
,
"rate_limit_error"
,
"Upstream rate limit exceeded, please retry later"
case
529
:
case
529
:
...
@@ -382,11 +499,67 @@ func (h *SoraGatewayHandler) mapUpstreamError(statusCode int) (int, string, stri
...
@@ -382,11 +499,67 @@ func (h *SoraGatewayHandler) mapUpstreamError(statusCode int) (int, string, stri
}
}
}
}
func
cloneHTTPHeaders
(
headers
http
.
Header
)
http
.
Header
{
if
headers
==
nil
{
return
nil
}
return
headers
.
Clone
()
}
func
extractSoraFailoverHeaderInsights
(
headers
http
.
Header
,
body
[]
byte
)
(
rayID
,
mitigated
,
contentType
string
)
{
if
headers
!=
nil
{
mitigated
=
strings
.
TrimSpace
(
headers
.
Get
(
"cf-mitigated"
))
contentType
=
strings
.
TrimSpace
(
headers
.
Get
(
"content-type"
))
if
contentType
==
""
{
contentType
=
strings
.
TrimSpace
(
headers
.
Get
(
"Content-Type"
))
}
}
rayID
=
soraerror
.
ExtractCloudflareRayID
(
headers
,
body
)
return
rayID
,
mitigated
,
contentType
}
func
isSoraCloudflareChallengeResponse
(
statusCode
int
,
headers
http
.
Header
,
body
[]
byte
)
bool
{
return
soraerror
.
IsCloudflareChallengeResponse
(
statusCode
,
headers
,
body
)
}
func
shouldPassthroughSoraUpstreamMessage
(
statusCode
int
,
message
string
)
bool
{
message
=
strings
.
TrimSpace
(
message
)
if
message
==
""
{
return
false
}
if
statusCode
==
http
.
StatusForbidden
||
statusCode
==
http
.
StatusTooManyRequests
{
lower
:=
strings
.
ToLower
(
message
)
if
strings
.
Contains
(
lower
,
"<html"
)
||
strings
.
Contains
(
lower
,
"<!doctype html"
)
||
strings
.
Contains
(
lower
,
"window._cf_chl_opt"
)
{
return
false
}
}
return
true
}
func
formatSoraCloudflareChallengeMessage
(
base
string
,
headers
http
.
Header
,
body
[]
byte
)
string
{
return
soraerror
.
FormatCloudflareChallengeMessage
(
base
,
headers
,
body
)
}
func
extractUpstreamErrorCodeAndMessage
(
body
[]
byte
)
(
string
,
string
)
{
return
soraerror
.
ExtractUpstreamErrorCodeAndMessage
(
body
)
}
func
(
h
*
SoraGatewayHandler
)
handleStreamingAwareError
(
c
*
gin
.
Context
,
status
int
,
errType
,
message
string
,
streamStarted
bool
)
{
func
(
h
*
SoraGatewayHandler
)
handleStreamingAwareError
(
c
*
gin
.
Context
,
status
int
,
errType
,
message
string
,
streamStarted
bool
)
{
if
streamStarted
{
if
streamStarted
{
flusher
,
ok
:=
c
.
Writer
.
(
http
.
Flusher
)
flusher
,
ok
:=
c
.
Writer
.
(
http
.
Flusher
)
if
ok
{
if
ok
{
errorEvent
:=
fmt
.
Sprintf
(
`event: error`
+
"
\n
"
+
`data: {"error": {"type": "%s", "message": "%s"}}`
+
"
\n\n
"
,
errType
,
message
)
errorData
:=
map
[
string
]
any
{
"error"
:
map
[
string
]
string
{
"type"
:
errType
,
"message"
:
message
,
},
}
jsonBytes
,
err
:=
json
.
Marshal
(
errorData
)
if
err
!=
nil
{
_
=
c
.
Error
(
err
)
return
}
errorEvent
:=
fmt
.
Sprintf
(
"event: error
\n
data: %s
\n\n
"
,
string
(
jsonBytes
))
if
_
,
err
:=
fmt
.
Fprint
(
c
.
Writer
,
errorEvent
);
err
!=
nil
{
if
_
,
err
:=
fmt
.
Fprint
(
c
.
Writer
,
errorEvent
);
err
!=
nil
{
_
=
c
.
Error
(
err
)
_
=
c
.
Error
(
err
)
}
}
...
...
backend/internal/handler/sora_gateway_handler_test.go
View file @
987589ea
...
@@ -43,6 +43,48 @@ func (s *stubSoraClient) CreateImageTask(ctx context.Context, account *service.A
...
@@ -43,6 +43,48 @@ func (s *stubSoraClient) CreateImageTask(ctx context.Context, account *service.A
func
(
s
*
stubSoraClient
)
CreateVideoTask
(
ctx
context
.
Context
,
account
*
service
.
Account
,
req
service
.
SoraVideoRequest
)
(
string
,
error
)
{
func
(
s
*
stubSoraClient
)
CreateVideoTask
(
ctx
context
.
Context
,
account
*
service
.
Account
,
req
service
.
SoraVideoRequest
)
(
string
,
error
)
{
return
"task-video"
,
nil
return
"task-video"
,
nil
}
}
func
(
s
*
stubSoraClient
)
CreateStoryboardTask
(
ctx
context
.
Context
,
account
*
service
.
Account
,
req
service
.
SoraStoryboardRequest
)
(
string
,
error
)
{
return
"task-video"
,
nil
}
func
(
s
*
stubSoraClient
)
UploadCharacterVideo
(
ctx
context
.
Context
,
account
*
service
.
Account
,
data
[]
byte
)
(
string
,
error
)
{
return
"cameo-1"
,
nil
}
func
(
s
*
stubSoraClient
)
GetCameoStatus
(
ctx
context
.
Context
,
account
*
service
.
Account
,
cameoID
string
)
(
*
service
.
SoraCameoStatus
,
error
)
{
return
&
service
.
SoraCameoStatus
{
Status
:
"finalized"
,
StatusMessage
:
"Completed"
,
DisplayNameHint
:
"Character"
,
UsernameHint
:
"user.character"
,
ProfileAssetURL
:
"https://example.com/avatar.webp"
,
},
nil
}
func
(
s
*
stubSoraClient
)
DownloadCharacterImage
(
ctx
context
.
Context
,
account
*
service
.
Account
,
imageURL
string
)
([]
byte
,
error
)
{
return
[]
byte
(
"avatar"
),
nil
}
func
(
s
*
stubSoraClient
)
UploadCharacterImage
(
ctx
context
.
Context
,
account
*
service
.
Account
,
data
[]
byte
)
(
string
,
error
)
{
return
"asset-pointer"
,
nil
}
func
(
s
*
stubSoraClient
)
FinalizeCharacter
(
ctx
context
.
Context
,
account
*
service
.
Account
,
req
service
.
SoraCharacterFinalizeRequest
)
(
string
,
error
)
{
return
"character-1"
,
nil
}
func
(
s
*
stubSoraClient
)
SetCharacterPublic
(
ctx
context
.
Context
,
account
*
service
.
Account
,
cameoID
string
)
error
{
return
nil
}
func
(
s
*
stubSoraClient
)
DeleteCharacter
(
ctx
context
.
Context
,
account
*
service
.
Account
,
characterID
string
)
error
{
return
nil
}
func
(
s
*
stubSoraClient
)
PostVideoForWatermarkFree
(
ctx
context
.
Context
,
account
*
service
.
Account
,
generationID
string
)
(
string
,
error
)
{
return
"s_post"
,
nil
}
func
(
s
*
stubSoraClient
)
DeletePost
(
ctx
context
.
Context
,
account
*
service
.
Account
,
postID
string
)
error
{
return
nil
}
func
(
s
*
stubSoraClient
)
GetWatermarkFreeURLCustom
(
ctx
context
.
Context
,
account
*
service
.
Account
,
parseURL
,
parseToken
,
postID
string
)
(
string
,
error
)
{
return
"https://example.com/no-watermark.mp4"
,
nil
}
func
(
s
*
stubSoraClient
)
EnhancePrompt
(
ctx
context
.
Context
,
account
*
service
.
Account
,
prompt
,
expansionLevel
string
,
durationS
int
)
(
string
,
error
)
{
return
"enhanced prompt"
,
nil
}
func
(
s
*
stubSoraClient
)
GetImageTask
(
ctx
context
.
Context
,
account
*
service
.
Account
,
taskID
string
)
(
*
service
.
SoraImageTaskStatus
,
error
)
{
func
(
s
*
stubSoraClient
)
GetImageTask
(
ctx
context
.
Context
,
account
*
service
.
Account
,
taskID
string
)
(
*
service
.
SoraImageTaskStatus
,
error
)
{
return
&
service
.
SoraImageTaskStatus
{
ID
:
taskID
,
Status
:
"completed"
,
URLs
:
s
.
imageURLs
},
nil
return
&
service
.
SoraImageTaskStatus
{
ID
:
taskID
,
Status
:
"completed"
,
URLs
:
s
.
imageURLs
},
nil
}
}
...
@@ -88,7 +130,7 @@ func (r *stubAccountRepo) Delete(ctx context.Context, id int64) error
...
@@ -88,7 +130,7 @@ func (r *stubAccountRepo) Delete(ctx context.Context, id int64) error
func
(
r
*
stubAccountRepo
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
service
.
Account
,
*
pagination
.
PaginationResult
,
error
)
{
func
(
r
*
stubAccountRepo
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
service
.
Account
,
*
pagination
.
PaginationResult
,
error
)
{
return
nil
,
nil
,
nil
return
nil
,
nil
,
nil
}
}
func
(
r
*
stubAccountRepo
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
platform
,
accountType
,
status
,
search
string
)
([]
service
.
Account
,
*
pagination
.
PaginationResult
,
error
)
{
func
(
r
*
stubAccountRepo
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
platform
,
accountType
,
status
,
search
string
,
groupID
int64
)
([]
service
.
Account
,
*
pagination
.
PaginationResult
,
error
)
{
return
nil
,
nil
,
nil
return
nil
,
nil
,
nil
}
}
func
(
r
*
stubAccountRepo
)
ListByGroup
(
ctx
context
.
Context
,
groupID
int64
)
([]
service
.
Account
,
error
)
{
func
(
r
*
stubAccountRepo
)
ListByGroup
(
ctx
context
.
Context
,
groupID
int64
)
([]
service
.
Account
,
error
)
{
...
@@ -495,3 +537,152 @@ func TestGenerateOpenAISessionHash_WithBody(t *testing.T) {
...
@@ -495,3 +537,152 @@ func TestGenerateOpenAISessionHash_WithBody(t *testing.T) {
require
.
NotEmpty
(
t
,
hash3
)
require
.
NotEmpty
(
t
,
hash3
)
require
.
NotEqual
(
t
,
hash
,
hash3
)
// 不同来源应产生不同 hash
require
.
NotEqual
(
t
,
hash
,
hash3
)
// 不同来源应产生不同 hash
}
}
func
TestSoraHandleStreamingAwareError_JSONEscaping
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
name
string
errType
string
message
string
}{
{
name
:
"包含双引号"
,
errType
:
"upstream_error"
,
message
:
`upstream returned "invalid" payload`
,
},
{
name
:
"包含换行和制表符"
,
errType
:
"rate_limit_error"
,
message
:
"line1
\n
line2
\t
tab"
,
},
{
name
:
"包含反斜杠"
,
errType
:
"upstream_error"
,
message
:
`path C:\Users\test\file.txt not found`
,
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/"
,
nil
)
h
:=
&
SoraGatewayHandler
{}
h
.
handleStreamingAwareError
(
c
,
http
.
StatusBadGateway
,
tt
.
errType
,
tt
.
message
,
true
)
body
:=
w
.
Body
.
String
()
require
.
True
(
t
,
strings
.
HasPrefix
(
body
,
"event: error
\n
"
),
"应以 SSE error 事件开头"
)
require
.
True
(
t
,
strings
.
HasSuffix
(
body
,
"
\n\n
"
),
"应以 SSE 结束分隔符结尾"
)
lines
:=
strings
.
Split
(
strings
.
TrimSuffix
(
body
,
"
\n\n
"
),
"
\n
"
)
require
.
Len
(
t
,
lines
,
2
,
"SSE 错误事件应包含 event 行和 data 行"
)
require
.
Equal
(
t
,
"event: error"
,
lines
[
0
])
require
.
True
(
t
,
strings
.
HasPrefix
(
lines
[
1
],
"data: "
),
"第二行应为 data 前缀"
)
jsonStr
:=
strings
.
TrimPrefix
(
lines
[
1
],
"data: "
)
var
parsed
map
[
string
]
any
require
.
NoError
(
t
,
json
.
Unmarshal
([]
byte
(
jsonStr
),
&
parsed
),
"data 行必须是合法 JSON"
)
errorObj
,
ok
:=
parsed
[
"error"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
,
"JSON 中应包含 error 对象"
)
require
.
Equal
(
t
,
tt
.
errType
,
errorObj
[
"type"
])
require
.
Equal
(
t
,
tt
.
message
,
errorObj
[
"message"
])
})
}
}
func
TestSoraHandleFailoverExhausted_StreamPassesUpstreamMessage
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/"
,
nil
)
h
:=
&
SoraGatewayHandler
{}
resp
:=
[]
byte
(
`{"error":{"message":"invalid \"prompt\"\nline2","code":"bad_request"}}`
)
h
.
handleFailoverExhausted
(
c
,
http
.
StatusBadGateway
,
nil
,
resp
,
true
)
body
:=
w
.
Body
.
String
()
require
.
True
(
t
,
strings
.
HasPrefix
(
body
,
"event: error
\n
"
))
require
.
True
(
t
,
strings
.
HasSuffix
(
body
,
"
\n\n
"
))
lines
:=
strings
.
Split
(
strings
.
TrimSuffix
(
body
,
"
\n\n
"
),
"
\n
"
)
require
.
Len
(
t
,
lines
,
2
)
jsonStr
:=
strings
.
TrimPrefix
(
lines
[
1
],
"data: "
)
var
parsed
map
[
string
]
any
require
.
NoError
(
t
,
json
.
Unmarshal
([]
byte
(
jsonStr
),
&
parsed
))
errorObj
,
ok
:=
parsed
[
"error"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
"upstream_error"
,
errorObj
[
"type"
])
require
.
Equal
(
t
,
"invalid
\"
prompt
\"\n
line2"
,
errorObj
[
"message"
])
}
func
TestSoraHandleFailoverExhausted_CloudflareChallengeIncludesRay
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/"
,
nil
)
headers
:=
http
.
Header
{}
headers
.
Set
(
"cf-ray"
,
"9d01b0e9ecc35829-SEA"
)
body
:=
[]
byte
(
`<!DOCTYPE html><html><head><title>Just a moment...</title></head><body><script>window._cf_chl_opt={};</script></body></html>`
)
h
:=
&
SoraGatewayHandler
{}
h
.
handleFailoverExhausted
(
c
,
http
.
StatusForbidden
,
headers
,
body
,
true
)
lines
:=
strings
.
Split
(
strings
.
TrimSuffix
(
w
.
Body
.
String
(),
"
\n\n
"
),
"
\n
"
)
require
.
Len
(
t
,
lines
,
2
)
jsonStr
:=
strings
.
TrimPrefix
(
lines
[
1
],
"data: "
)
var
parsed
map
[
string
]
any
require
.
NoError
(
t
,
json
.
Unmarshal
([]
byte
(
jsonStr
),
&
parsed
))
errorObj
,
ok
:=
parsed
[
"error"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
"upstream_error"
,
errorObj
[
"type"
])
msg
,
_
:=
errorObj
[
"message"
]
.
(
string
)
require
.
Contains
(
t
,
msg
,
"Cloudflare challenge"
)
require
.
Contains
(
t
,
msg
,
"cf-ray: 9d01b0e9ecc35829-SEA"
)
}
func
TestSoraHandleFailoverExhausted_CfShield429MappedToRateLimitError
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/"
,
nil
)
headers
:=
http
.
Header
{}
headers
.
Set
(
"cf-ray"
,
"9d03b68c086027a1-SEA"
)
body
:=
[]
byte
(
`{"error":{"code":"cf_shield_429","message":"shield blocked"}}`
)
h
:=
&
SoraGatewayHandler
{}
h
.
handleFailoverExhausted
(
c
,
http
.
StatusTooManyRequests
,
headers
,
body
,
true
)
lines
:=
strings
.
Split
(
strings
.
TrimSuffix
(
w
.
Body
.
String
(),
"
\n\n
"
),
"
\n
"
)
require
.
Len
(
t
,
lines
,
2
)
jsonStr
:=
strings
.
TrimPrefix
(
lines
[
1
],
"data: "
)
var
parsed
map
[
string
]
any
require
.
NoError
(
t
,
json
.
Unmarshal
([]
byte
(
jsonStr
),
&
parsed
))
errorObj
,
ok
:=
parsed
[
"error"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
"rate_limit_error"
,
errorObj
[
"type"
])
msg
,
_
:=
errorObj
[
"message"
]
.
(
string
)
require
.
Contains
(
t
,
msg
,
"Cloudflare shield"
)
require
.
Contains
(
t
,
msg
,
"cf-ray: 9d03b68c086027a1-SEA"
)
}
func
TestExtractSoraFailoverHeaderInsights
(
t
*
testing
.
T
)
{
headers
:=
http
.
Header
{}
headers
.
Set
(
"cf-mitigated"
,
"challenge"
)
headers
.
Set
(
"content-type"
,
"text/html"
)
body
:=
[]
byte
(
`<script>window._cf_chl_opt={cRay: '9cff2d62d83bb98d'};</script>`
)
rayID
,
mitigated
,
contentType
:=
extractSoraFailoverHeaderInsights
(
headers
,
body
)
require
.
Equal
(
t
,
"9cff2d62d83bb98d"
,
rayID
)
require
.
Equal
(
t
,
"challenge"
,
mitigated
)
require
.
Equal
(
t
,
"text/html"
,
contentType
)
}
backend/internal/pkg/claude/constants.go
View file @
987589ea
...
@@ -10,6 +10,7 @@ const (
...
@@ -10,6 +10,7 @@ const (
BetaInterleavedThinking
=
"interleaved-thinking-2025-05-14"
BetaInterleavedThinking
=
"interleaved-thinking-2025-05-14"
BetaFineGrainedToolStreaming
=
"fine-grained-tool-streaming-2025-05-14"
BetaFineGrainedToolStreaming
=
"fine-grained-tool-streaming-2025-05-14"
BetaTokenCounting
=
"token-counting-2024-11-01"
BetaTokenCounting
=
"token-counting-2024-11-01"
BetaContext1M
=
"context-1m-2025-08-07"
)
)
// DefaultBetaHeader Claude Code 客户端默认的 anthropic-beta header
// DefaultBetaHeader Claude Code 客户端默认的 anthropic-beta header
...
@@ -77,6 +78,12 @@ var DefaultModels = []Model{
...
@@ -77,6 +78,12 @@ var DefaultModels = []Model{
DisplayName
:
"Claude Opus 4.6"
,
DisplayName
:
"Claude Opus 4.6"
,
CreatedAt
:
"2026-02-06T00:00:00Z"
,
CreatedAt
:
"2026-02-06T00:00:00Z"
,
},
},
{
ID
:
"claude-sonnet-4-6"
,
Type
:
"model"
,
DisplayName
:
"Claude Sonnet 4.6"
,
CreatedAt
:
"2026-02-18T00:00:00Z"
,
},
{
{
ID
:
"claude-sonnet-4-5-20250929"
,
ID
:
"claude-sonnet-4-5-20250929"
,
Type
:
"model"
,
Type
:
"model"
,
...
...
backend/internal/pkg/openai/oauth.go
View file @
987589ea
...
@@ -17,6 +17,8 @@ import (
...
@@ -17,6 +17,8 @@ import (
const
(
const
(
// OAuth Client ID for OpenAI (Codex CLI official)
// OAuth Client ID for OpenAI (Codex CLI official)
ClientID
=
"app_EMoamEEZ73f0CkXaXp7hrann"
ClientID
=
"app_EMoamEEZ73f0CkXaXp7hrann"
// OAuth Client ID for Sora mobile flow (aligned with sora2api)
SoraClientID
=
"app_LlGpXReQgckcGGUo2JrYvtJK"
// OAuth endpoints
// OAuth endpoints
AuthorizeURL
=
"https://auth.openai.com/oauth/authorize"
AuthorizeURL
=
"https://auth.openai.com/oauth/authorize"
...
...
backend/internal/repository/account_repo.go
View file @
987589ea
...
@@ -435,10 +435,10 @@ func (r *accountRepository) Delete(ctx context.Context, id int64) error {
...
@@ -435,10 +435,10 @@ func (r *accountRepository) Delete(ctx context.Context, id int64) error {
}
}
func
(
r
*
accountRepository
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
service
.
Account
,
*
pagination
.
PaginationResult
,
error
)
{
func
(
r
*
accountRepository
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
service
.
Account
,
*
pagination
.
PaginationResult
,
error
)
{
return
r
.
ListWithFilters
(
ctx
,
params
,
""
,
""
,
""
,
""
)
return
r
.
ListWithFilters
(
ctx
,
params
,
""
,
""
,
""
,
""
,
0
)
}
}
func
(
r
*
accountRepository
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
platform
,
accountType
,
status
,
search
string
)
([]
service
.
Account
,
*
pagination
.
PaginationResult
,
error
)
{
func
(
r
*
accountRepository
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
platform
,
accountType
,
status
,
search
string
,
groupID
int64
)
([]
service
.
Account
,
*
pagination
.
PaginationResult
,
error
)
{
q
:=
r
.
client
.
Account
.
Query
()
q
:=
r
.
client
.
Account
.
Query
()
if
platform
!=
""
{
if
platform
!=
""
{
...
@@ -458,6 +458,9 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati
...
@@ -458,6 +458,9 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati
if
search
!=
""
{
if
search
!=
""
{
q
=
q
.
Where
(
dbaccount
.
NameContainsFold
(
search
))
q
=
q
.
Where
(
dbaccount
.
NameContainsFold
(
search
))
}
}
if
groupID
>
0
{
q
=
q
.
Where
(
dbaccount
.
HasAccountGroupsWith
(
dbaccountgroup
.
GroupIDEQ
(
groupID
)))
}
total
,
err
:=
q
.
Count
(
ctx
)
total
,
err
:=
q
.
Count
(
ctx
)
if
err
!=
nil
{
if
err
!=
nil
{
...
...
backend/internal/repository/account_repo_integration_test.go
View file @
987589ea
...
@@ -238,7 +238,7 @@ func (s *AccountRepoSuite) TestListWithFilters() {
...
@@ -238,7 +238,7 @@ func (s *AccountRepoSuite) TestListWithFilters() {
tt
.
setup
(
client
)
tt
.
setup
(
client
)
accounts
,
_
,
err
:=
repo
.
ListWithFilters
(
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
},
tt
.
platform
,
tt
.
accType
,
tt
.
status
,
tt
.
search
)
accounts
,
_
,
err
:=
repo
.
ListWithFilters
(
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
},
tt
.
platform
,
tt
.
accType
,
tt
.
status
,
tt
.
search
,
0
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Len
(
accounts
,
tt
.
wantCount
)
s
.
Require
()
.
Len
(
accounts
,
tt
.
wantCount
)
if
tt
.
validate
!=
nil
{
if
tt
.
validate
!=
nil
{
...
@@ -305,7 +305,7 @@ func (s *AccountRepoSuite) TestPreload_And_VirtualFields() {
...
@@ -305,7 +305,7 @@ func (s *AccountRepoSuite) TestPreload_And_VirtualFields() {
s
.
Require
()
.
Len
(
got
.
Groups
,
1
,
"expected Groups to be populated"
)
s
.
Require
()
.
Len
(
got
.
Groups
,
1
,
"expected Groups to be populated"
)
s
.
Require
()
.
Equal
(
group
.
ID
,
got
.
Groups
[
0
]
.
ID
)
s
.
Require
()
.
Equal
(
group
.
ID
,
got
.
Groups
[
0
]
.
ID
)
accounts
,
page
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
},
""
,
""
,
""
,
"acc"
)
accounts
,
page
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
},
""
,
""
,
""
,
"acc"
,
0
)
s
.
Require
()
.
NoError
(
err
,
"ListWithFilters"
)
s
.
Require
()
.
NoError
(
err
,
"ListWithFilters"
)
s
.
Require
()
.
Equal
(
int64
(
1
),
page
.
Total
)
s
.
Require
()
.
Equal
(
int64
(
1
),
page
.
Total
)
s
.
Require
()
.
Len
(
accounts
,
1
)
s
.
Require
()
.
Len
(
accounts
,
1
)
...
...
backend/internal/repository/openai_oauth_service.go
View file @
987589ea
...
@@ -4,6 +4,7 @@ import (
...
@@ -4,6 +4,7 @@ import (
"context"
"context"
"net/http"
"net/http"
"net/url"
"net/url"
"strings"
"time"
"time"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
...
@@ -56,12 +57,49 @@ func (s *openaiOAuthService) ExchangeCode(ctx context.Context, code, codeVerifie
...
@@ -56,12 +57,49 @@ func (s *openaiOAuthService) ExchangeCode(ctx context.Context, code, codeVerifie
}
}
func
(
s
*
openaiOAuthService
)
RefreshToken
(
ctx
context
.
Context
,
refreshToken
,
proxyURL
string
)
(
*
openai
.
TokenResponse
,
error
)
{
func
(
s
*
openaiOAuthService
)
RefreshToken
(
ctx
context
.
Context
,
refreshToken
,
proxyURL
string
)
(
*
openai
.
TokenResponse
,
error
)
{
return
s
.
RefreshTokenWithClientID
(
ctx
,
refreshToken
,
proxyURL
,
""
)
}
func
(
s
*
openaiOAuthService
)
RefreshTokenWithClientID
(
ctx
context
.
Context
,
refreshToken
,
proxyURL
string
,
clientID
string
)
(
*
openai
.
TokenResponse
,
error
)
{
if
strings
.
TrimSpace
(
clientID
)
!=
""
{
return
s
.
refreshTokenWithClientID
(
ctx
,
refreshToken
,
proxyURL
,
strings
.
TrimSpace
(
clientID
))
}
clientIDs
:=
[]
string
{
openai
.
ClientID
,
openai
.
SoraClientID
,
}
seen
:=
make
(
map
[
string
]
struct
{},
len
(
clientIDs
))
var
lastErr
error
for
_
,
clientID
:=
range
clientIDs
{
clientID
=
strings
.
TrimSpace
(
clientID
)
if
clientID
==
""
{
continue
}
if
_
,
ok
:=
seen
[
clientID
];
ok
{
continue
}
seen
[
clientID
]
=
struct
{}{}
tokenResp
,
err
:=
s
.
refreshTokenWithClientID
(
ctx
,
refreshToken
,
proxyURL
,
clientID
)
if
err
==
nil
{
return
tokenResp
,
nil
}
lastErr
=
err
}
if
lastErr
!=
nil
{
return
nil
,
lastErr
}
return
nil
,
infraerrors
.
New
(
http
.
StatusBadGateway
,
"OPENAI_OAUTH_TOKEN_REFRESH_FAILED"
,
"token refresh failed"
)
}
func
(
s
*
openaiOAuthService
)
refreshTokenWithClientID
(
ctx
context
.
Context
,
refreshToken
,
proxyURL
,
clientID
string
)
(
*
openai
.
TokenResponse
,
error
)
{
client
:=
createOpenAIReqClient
(
proxyURL
)
client
:=
createOpenAIReqClient
(
proxyURL
)
formData
:=
url
.
Values
{}
formData
:=
url
.
Values
{}
formData
.
Set
(
"grant_type"
,
"refresh_token"
)
formData
.
Set
(
"grant_type"
,
"refresh_token"
)
formData
.
Set
(
"refresh_token"
,
refreshToken
)
formData
.
Set
(
"refresh_token"
,
refreshToken
)
formData
.
Set
(
"client_id"
,
openai
.
C
lientID
)
formData
.
Set
(
"client_id"
,
c
lientID
)
formData
.
Set
(
"scope"
,
openai
.
RefreshScopes
)
formData
.
Set
(
"scope"
,
openai
.
RefreshScopes
)
var
tokenResp
openai
.
TokenResponse
var
tokenResp
openai
.
TokenResponse
...
...
backend/internal/repository/openai_oauth_service_test.go
View file @
987589ea
...
@@ -136,6 +136,60 @@ func (s *OpenAIOAuthServiceSuite) TestRefreshToken_FormFields() {
...
@@ -136,6 +136,60 @@ func (s *OpenAIOAuthServiceSuite) TestRefreshToken_FormFields() {
require
.
Equal
(
s
.
T
(),
"rt2"
,
resp
.
RefreshToken
)
require
.
Equal
(
s
.
T
(),
"rt2"
,
resp
.
RefreshToken
)
}
}
func
(
s
*
OpenAIOAuthServiceSuite
)
TestRefreshToken_FallbackToSoraClientID
()
{
var
seenClientIDs
[]
string
s
.
setupServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
if
err
:=
r
.
ParseForm
();
err
!=
nil
{
w
.
WriteHeader
(
http
.
StatusBadRequest
)
return
}
clientID
:=
r
.
PostForm
.
Get
(
"client_id"
)
seenClientIDs
=
append
(
seenClientIDs
,
clientID
)
if
clientID
==
openai
.
ClientID
{
w
.
WriteHeader
(
http
.
StatusBadRequest
)
_
,
_
=
io
.
WriteString
(
w
,
"invalid_grant"
)
return
}
if
clientID
==
openai
.
SoraClientID
{
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
io
.
WriteString
(
w
,
`{"access_token":"at-sora","refresh_token":"rt-sora","token_type":"bearer","expires_in":3600}`
)
return
}
w
.
WriteHeader
(
http
.
StatusBadRequest
)
}))
resp
,
err
:=
s
.
svc
.
RefreshToken
(
s
.
ctx
,
"rt"
,
""
)
require
.
NoError
(
s
.
T
(),
err
,
"RefreshToken"
)
require
.
Equal
(
s
.
T
(),
"at-sora"
,
resp
.
AccessToken
)
require
.
Equal
(
s
.
T
(),
"rt-sora"
,
resp
.
RefreshToken
)
require
.
Equal
(
s
.
T
(),
[]
string
{
openai
.
ClientID
,
openai
.
SoraClientID
},
seenClientIDs
)
}
func
(
s
*
OpenAIOAuthServiceSuite
)
TestRefreshToken_UseProvidedClientID
()
{
const
customClientID
=
"custom-client-id"
var
seenClientIDs
[]
string
s
.
setupServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
if
err
:=
r
.
ParseForm
();
err
!=
nil
{
w
.
WriteHeader
(
http
.
StatusBadRequest
)
return
}
clientID
:=
r
.
PostForm
.
Get
(
"client_id"
)
seenClientIDs
=
append
(
seenClientIDs
,
clientID
)
if
clientID
!=
customClientID
{
w
.
WriteHeader
(
http
.
StatusBadRequest
)
return
}
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
io
.
WriteString
(
w
,
`{"access_token":"at-custom","refresh_token":"rt-custom","token_type":"bearer","expires_in":3600}`
)
}))
resp
,
err
:=
s
.
svc
.
RefreshTokenWithClientID
(
s
.
ctx
,
"rt"
,
""
,
customClientID
)
require
.
NoError
(
s
.
T
(),
err
,
"RefreshTokenWithClientID"
)
require
.
Equal
(
s
.
T
(),
"at-custom"
,
resp
.
AccessToken
)
require
.
Equal
(
s
.
T
(),
"rt-custom"
,
resp
.
RefreshToken
)
require
.
Equal
(
s
.
T
(),
[]
string
{
customClientID
},
seenClientIDs
)
}
func
(
s
*
OpenAIOAuthServiceSuite
)
TestNonSuccessStatus_IncludesBody
()
{
func
(
s
*
OpenAIOAuthServiceSuite
)
TestNonSuccessStatus_IncludesBody
()
{
s
.
setupServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
s
.
setupServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
w
.
WriteHeader
(
http
.
StatusBadRequest
)
w
.
WriteHeader
(
http
.
StatusBadRequest
)
...
...
backend/internal/repository/usage_log_repo.go
View file @
987589ea
...
@@ -22,7 +22,7 @@ import (
...
@@ -22,7 +22,7 @@ import (
"github.com/lib/pq"
"github.com/lib/pq"
)
)
const
usageLogSelectColumns
=
"id, user_id, api_key_id, account_id, request_id, model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, stream, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, media_type, reasoning_effort, created_at"
const
usageLogSelectColumns
=
"id, user_id, api_key_id, account_id, request_id, model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, stream, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, media_type, reasoning_effort,
cache_ttl_overridden,
created_at"
// dateFormatWhitelist 将 granularity 参数映射为 PostgreSQL TO_CHAR 格式字符串,防止外部输入直接拼入 SQL
// dateFormatWhitelist 将 granularity 参数映射为 PostgreSQL TO_CHAR 格式字符串,防止外部输入直接拼入 SQL
var
dateFormatWhitelist
=
map
[
string
]
string
{
var
dateFormatWhitelist
=
map
[
string
]
string
{
...
@@ -132,6 +132,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
...
@@ -132,6 +132,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
image_size,
image_size,
media_type,
media_type,
reasoning_effort,
reasoning_effort,
cache_ttl_overridden,
created_at
created_at
) VALUES (
) VALUES (
$1, $2, $3, $4, $5,
$1, $2, $3, $4, $5,
...
@@ -139,7 +140,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
...
@@ -139,7 +140,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
$8, $9, $10, $11,
$8, $9, $10, $11,
$12, $13,
$12, $13,
$14, $15, $16, $17, $18, $19,
$14, $15, $16, $17, $18, $19,
$20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32
$20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32
, $33
)
)
ON CONFLICT (request_id, api_key_id) DO NOTHING
ON CONFLICT (request_id, api_key_id) DO NOTHING
RETURNING id, created_at
RETURNING id, created_at
...
@@ -192,6 +193,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
...
@@ -192,6 +193,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
imageSize
,
imageSize
,
mediaType
,
mediaType
,
reasoningEffort
,
reasoningEffort
,
log
.
CacheTTLOverridden
,
createdAt
,
createdAt
,
}
}
if
err
:=
scanSingleRow
(
ctx
,
sqlq
,
query
,
args
,
&
log
.
ID
,
&
log
.
CreatedAt
);
err
!=
nil
{
if
err
:=
scanSingleRow
(
ctx
,
sqlq
,
query
,
args
,
&
log
.
ID
,
&
log
.
CreatedAt
);
err
!=
nil
{
...
@@ -2221,6 +2223,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
...
@@ -2221,6 +2223,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
imageSize
sql
.
NullString
imageSize
sql
.
NullString
mediaType
sql
.
NullString
mediaType
sql
.
NullString
reasoningEffort
sql
.
NullString
reasoningEffort
sql
.
NullString
cacheTTLOverridden
bool
createdAt
time
.
Time
createdAt
time
.
Time
)
)
...
@@ -2257,6 +2260,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
...
@@ -2257,6 +2260,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
&
imageSize
,
&
imageSize
,
&
mediaType
,
&
mediaType
,
&
reasoningEffort
,
&
reasoningEffort
,
&
cacheTTLOverridden
,
&
createdAt
,
&
createdAt
,
);
err
!=
nil
{
);
err
!=
nil
{
return
nil
,
err
return
nil
,
err
...
@@ -2285,6 +2289,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
...
@@ -2285,6 +2289,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
BillingType
:
int8
(
billingType
),
BillingType
:
int8
(
billingType
),
Stream
:
stream
,
Stream
:
stream
,
ImageCount
:
imageCount
,
ImageCount
:
imageCount
,
CacheTTLOverridden
:
cacheTTLOverridden
,
CreatedAt
:
createdAt
,
CreatedAt
:
createdAt
,
}
}
...
...
backend/internal/server/api_contract_test.go
View file @
987589ea
...
@@ -406,6 +406,7 @@ func TestAPIContracts(t *testing.T) {
...
@@ -406,6 +406,7 @@ func TestAPIContracts(t *testing.T) {
"image_count": 0,
"image_count": 0,
"image_size": null,
"image_size": null,
"media_type": null,
"media_type": null,
"cache_ttl_overridden": false,
"created_at": "2025-01-02T03:04:05Z",
"created_at": "2025-01-02T03:04:05Z",
"user_agent": null
"user_agent": null
}
}
...
@@ -945,7 +946,7 @@ func (s *stubAccountRepo) List(ctx context.Context, params pagination.Pagination
...
@@ -945,7 +946,7 @@ func (s *stubAccountRepo) List(ctx context.Context, params pagination.Pagination
return
nil
,
nil
,
errors
.
New
(
"not implemented"
)
return
nil
,
nil
,
errors
.
New
(
"not implemented"
)
}
}
func
(
s
*
stubAccountRepo
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
platform
,
accountType
,
status
,
search
string
)
([]
service
.
Account
,
*
pagination
.
PaginationResult
,
error
)
{
func
(
s
*
stubAccountRepo
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
platform
,
accountType
,
status
,
search
string
,
groupID
int64
)
([]
service
.
Account
,
*
pagination
.
PaginationResult
,
error
)
{
return
nil
,
nil
,
errors
.
New
(
"not implemented"
)
return
nil
,
nil
,
errors
.
New
(
"not implemented"
)
}
}
...
...
backend/internal/server/middleware/cors.go
View file @
987589ea
...
@@ -50,6 +50,19 @@ func CORS(cfg config.CORSConfig) gin.HandlerFunc {
...
@@ -50,6 +50,19 @@ func CORS(cfg config.CORSConfig) gin.HandlerFunc {
}
}
allowedSet
[
origin
]
=
struct
{}{}
allowedSet
[
origin
]
=
struct
{}{}
}
}
allowHeaders
:=
[]
string
{
"Content-Type"
,
"Content-Length"
,
"Accept-Encoding"
,
"X-CSRF-Token"
,
"Authorization"
,
"accept"
,
"origin"
,
"Cache-Control"
,
"X-Requested-With"
,
"X-API-Key"
,
}
// OpenAI Node SDK 会发送 x-stainless-* 请求头,需在 CORS 中显式放行。
openAIProperties
:=
[]
string
{
"lang"
,
"package-version"
,
"os"
,
"arch"
,
"retry-count"
,
"runtime"
,
"runtime-version"
,
"async"
,
"helper-method"
,
"poll-helper"
,
"custom-poll-interval"
,
"timeout"
,
}
for
_
,
prop
:=
range
openAIProperties
{
allowHeaders
=
append
(
allowHeaders
,
"x-stainless-"
+
prop
)
}
allowHeadersValue
:=
strings
.
Join
(
allowHeaders
,
", "
)
return
func
(
c
*
gin
.
Context
)
{
return
func
(
c
*
gin
.
Context
)
{
origin
:=
strings
.
TrimSpace
(
c
.
GetHeader
(
"Origin"
))
origin
:=
strings
.
TrimSpace
(
c
.
GetHeader
(
"Origin"
))
...
@@ -68,12 +81,11 @@ func CORS(cfg config.CORSConfig) gin.HandlerFunc {
...
@@ -68,12 +81,11 @@ func CORS(cfg config.CORSConfig) gin.HandlerFunc {
if
allowCredentials
{
if
allowCredentials
{
c
.
Writer
.
Header
()
.
Set
(
"Access-Control-Allow-Credentials"
,
"true"
)
c
.
Writer
.
Header
()
.
Set
(
"Access-Control-Allow-Credentials"
,
"true"
)
}
}
c
.
Writer
.
Header
()
.
Set
(
"Access-Control-Allow-Headers"
,
"Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, X-API-Key"
)
c
.
Writer
.
Header
()
.
Set
(
"Access-Control-Allow-Headers"
,
allowHeadersValue
)
c
.
Writer
.
Header
()
.
Set
(
"Access-Control-Allow-Methods"
,
"POST, OPTIONS, GET, PUT, DELETE, PATCH"
)
c
.
Writer
.
Header
()
.
Set
(
"Access-Control-Allow-Methods"
,
"POST, OPTIONS, GET, PUT, DELETE, PATCH"
)
c
.
Writer
.
Header
()
.
Set
(
"Access-Control-Expose-Headers"
,
"ETag"
)
c
.
Writer
.
Header
()
.
Set
(
"Access-Control-Expose-Headers"
,
"ETag"
)
c
.
Writer
.
Header
()
.
Set
(
"Access-Control-Max-Age"
,
"86400"
)
c
.
Writer
.
Header
()
.
Set
(
"Access-Control-Max-Age"
,
"86400"
)
}
}
// 处理预检请求
// 处理预检请求
if
c
.
Request
.
Method
==
http
.
MethodOptions
{
if
c
.
Request
.
Method
==
http
.
MethodOptions
{
if
originAllowed
{
if
originAllowed
{
...
...
backend/internal/server/routes/admin.go
View file @
987589ea
...
@@ -34,6 +34,8 @@ func RegisterAdminRoutes(
...
@@ -34,6 +34,8 @@ func RegisterAdminRoutes(
// OpenAI OAuth
// OpenAI OAuth
registerOpenAIOAuthRoutes
(
admin
,
h
)
registerOpenAIOAuthRoutes
(
admin
,
h
)
// Sora OAuth(实现复用 OpenAI OAuth 服务,入口独立)
registerSoraOAuthRoutes
(
admin
,
h
)
// Gemini OAuth
// Gemini OAuth
registerGeminiOAuthRoutes
(
admin
,
h
)
registerGeminiOAuthRoutes
(
admin
,
h
)
...
@@ -276,6 +278,19 @@ func registerOpenAIOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
...
@@ -276,6 +278,19 @@ func registerOpenAIOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
}
}
}
}
func
registerSoraOAuthRoutes
(
admin
*
gin
.
RouterGroup
,
h
*
handler
.
Handlers
)
{
sora
:=
admin
.
Group
(
"/sora"
)
{
sora
.
POST
(
"/generate-auth-url"
,
h
.
Admin
.
OpenAIOAuth
.
GenerateAuthURL
)
sora
.
POST
(
"/exchange-code"
,
h
.
Admin
.
OpenAIOAuth
.
ExchangeCode
)
sora
.
POST
(
"/refresh-token"
,
h
.
Admin
.
OpenAIOAuth
.
RefreshToken
)
sora
.
POST
(
"/st2at"
,
h
.
Admin
.
OpenAIOAuth
.
ExchangeSoraSessionToken
)
sora
.
POST
(
"/rt2at"
,
h
.
Admin
.
OpenAIOAuth
.
RefreshToken
)
sora
.
POST
(
"/accounts/:id/refresh"
,
h
.
Admin
.
OpenAIOAuth
.
RefreshAccountToken
)
sora
.
POST
(
"/create-from-oauth"
,
h
.
Admin
.
OpenAIOAuth
.
CreateAccountFromOAuth
)
}
}
func
registerGeminiOAuthRoutes
(
admin
*
gin
.
RouterGroup
,
h
*
handler
.
Handlers
)
{
func
registerGeminiOAuthRoutes
(
admin
*
gin
.
RouterGroup
,
h
*
handler
.
Handlers
)
{
gemini
:=
admin
.
Group
(
"/gemini"
)
gemini
:=
admin
.
Group
(
"/gemini"
)
{
{
...
@@ -306,6 +321,7 @@ func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
...
@@ -306,6 +321,7 @@ func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
proxies
.
PUT
(
"/:id"
,
h
.
Admin
.
Proxy
.
Update
)
proxies
.
PUT
(
"/:id"
,
h
.
Admin
.
Proxy
.
Update
)
proxies
.
DELETE
(
"/:id"
,
h
.
Admin
.
Proxy
.
Delete
)
proxies
.
DELETE
(
"/:id"
,
h
.
Admin
.
Proxy
.
Delete
)
proxies
.
POST
(
"/:id/test"
,
h
.
Admin
.
Proxy
.
Test
)
proxies
.
POST
(
"/:id/test"
,
h
.
Admin
.
Proxy
.
Test
)
proxies
.
POST
(
"/:id/quality-check"
,
h
.
Admin
.
Proxy
.
CheckQuality
)
proxies
.
GET
(
"/:id/stats"
,
h
.
Admin
.
Proxy
.
GetStats
)
proxies
.
GET
(
"/:id/stats"
,
h
.
Admin
.
Proxy
.
GetStats
)
proxies
.
GET
(
"/:id/accounts"
,
h
.
Admin
.
Proxy
.
GetProxyAccounts
)
proxies
.
GET
(
"/:id/accounts"
,
h
.
Admin
.
Proxy
.
GetProxyAccounts
)
proxies
.
POST
(
"/batch-delete"
,
h
.
Admin
.
Proxy
.
BatchDelete
)
proxies
.
POST
(
"/batch-delete"
,
h
.
Admin
.
Proxy
.
BatchDelete
)
...
...
backend/internal/server/routes/gateway.go
View file @
987589ea
package
routes
package
routes
import
(
import
(
"net/http"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
...
@@ -41,16 +43,15 @@ func RegisterGatewayRoutes(
...
@@ -41,16 +43,15 @@ func RegisterGatewayRoutes(
gateway
.
GET
(
"/usage"
,
h
.
Gateway
.
Usage
)
gateway
.
GET
(
"/usage"
,
h
.
Gateway
.
Usage
)
// OpenAI Responses API
// OpenAI Responses API
gateway
.
POST
(
"/responses"
,
h
.
OpenAIGateway
.
Responses
)
gateway
.
POST
(
"/responses"
,
h
.
OpenAIGateway
.
Responses
)
}
// 明确阻止旧入口误用到 Sora,避免客户端把 OpenAI Chat Completions 当作 Sora 入口
gateway
.
POST
(
"/chat/completions"
,
func
(
c
*
gin
.
Context
)
{
// Sora Chat Completions
c
.
JSON
(
http
.
StatusBadRequest
,
gin
.
H
{
soraGateway
:=
r
.
Group
(
"/v1"
)
"error"
:
gin
.
H
{
soraGateway
.
Use
(
soraBodyLimit
)
"type"
:
"invalid_request_error"
,
soraGateway
.
Use
(
clientRequestID
)
"message"
:
"For Sora, use /sora/v1/chat/completions. OpenAI should use /v1/responses."
,
soraGateway
.
Use
(
opsErrorLogger
)
},
soraGateway
.
Use
(
gin
.
HandlerFunc
(
apiKeyAuth
))
})
{
})
soraGateway
.
POST
(
"/chat/completions"
,
h
.
SoraGateway
.
ChatCompletions
)
}
}
// Gemini 原生 API 兼容层(Gemini SDK/CLI 直连)
// Gemini 原生 API 兼容层(Gemini SDK/CLI 直连)
...
...
backend/internal/service/account.go
View file @
987589ea
...
@@ -786,6 +786,38 @@ func (a *Account) IsSessionIDMaskingEnabled() bool {
...
@@ -786,6 +786,38 @@ func (a *Account) IsSessionIDMaskingEnabled() bool {
return
false
return
false
}
}
// IsCacheTTLOverrideEnabled 检查是否启用缓存 TTL 强制替换
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
// 启用后将所有 cache creation tokens 归入指定的 TTL 类型(5m 或 1h)
func
(
a
*
Account
)
IsCacheTTLOverrideEnabled
()
bool
{
if
!
a
.
IsAnthropicOAuthOrSetupToken
()
{
return
false
}
if
a
.
Extra
==
nil
{
return
false
}
if
v
,
ok
:=
a
.
Extra
[
"cache_ttl_override_enabled"
];
ok
{
if
enabled
,
ok
:=
v
.
(
bool
);
ok
{
return
enabled
}
}
return
false
}
// GetCacheTTLOverrideTarget 获取缓存 TTL 强制替换的目标类型
// 返回 "5m" 或 "1h",默认 "5m"
func
(
a
*
Account
)
GetCacheTTLOverrideTarget
()
string
{
if
a
.
Extra
==
nil
{
return
"5m"
}
if
v
,
ok
:=
a
.
Extra
[
"cache_ttl_override_target"
];
ok
{
if
target
,
ok
:=
v
.
(
string
);
ok
&&
(
target
==
"5m"
||
target
==
"1h"
)
{
return
target
}
}
return
"5m"
}
// GetWindowCostLimit 获取 5h 窗口费用阈值(美元)
// GetWindowCostLimit 获取 5h 窗口费用阈值(美元)
// 返回 0 表示未启用
// 返回 0 表示未启用
func
(
a
*
Account
)
GetWindowCostLimit
()
float64
{
func
(
a
*
Account
)
GetWindowCostLimit
()
float64
{
...
...
backend/internal/service/account_service.go
View file @
987589ea
...
@@ -35,7 +35,7 @@ type AccountRepository interface {
...
@@ -35,7 +35,7 @@ type AccountRepository interface {
Delete
(
ctx
context
.
Context
,
id
int64
)
error
Delete
(
ctx
context
.
Context
,
id
int64
)
error
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
Account
,
*
pagination
.
PaginationResult
,
error
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
Account
,
*
pagination
.
PaginationResult
,
error
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
platform
,
accountType
,
status
,
search
string
)
([]
Account
,
*
pagination
.
PaginationResult
,
error
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
platform
,
accountType
,
status
,
search
string
,
groupID
int64
)
([]
Account
,
*
pagination
.
PaginationResult
,
error
)
ListByGroup
(
ctx
context
.
Context
,
groupID
int64
)
([]
Account
,
error
)
ListByGroup
(
ctx
context
.
Context
,
groupID
int64
)
([]
Account
,
error
)
ListActive
(
ctx
context
.
Context
)
([]
Account
,
error
)
ListActive
(
ctx
context
.
Context
)
([]
Account
,
error
)
ListByPlatform
(
ctx
context
.
Context
,
platform
string
)
([]
Account
,
error
)
ListByPlatform
(
ctx
context
.
Context
,
platform
string
)
([]
Account
,
error
)
...
...
backend/internal/service/account_service_delete_test.go
View file @
987589ea
...
@@ -79,7 +79,7 @@ func (s *accountRepoStub) List(ctx context.Context, params pagination.Pagination
...
@@ -79,7 +79,7 @@ func (s *accountRepoStub) List(ctx context.Context, params pagination.Pagination
panic
(
"unexpected List call"
)
panic
(
"unexpected List call"
)
}
}
func
(
s
*
accountRepoStub
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
platform
,
accountType
,
status
,
search
string
)
([]
Account
,
*
pagination
.
PaginationResult
,
error
)
{
func
(
s
*
accountRepoStub
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
platform
,
accountType
,
status
,
search
string
,
groupID
int64
)
([]
Account
,
*
pagination
.
PaginationResult
,
error
)
{
panic
(
"unexpected ListWithFilters call"
)
panic
(
"unexpected ListWithFilters call"
)
}
}
...
...
backend/internal/service/account_test_service.go
View file @
987589ea
...
@@ -12,13 +12,17 @@ import (
...
@@ -12,13 +12,17 @@ import (
"io"
"io"
"log"
"log"
"net/http"
"net/http"
"net/url"
"regexp"
"regexp"
"strings"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/util/soraerror"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/google/uuid"
...
@@ -32,6 +36,10 @@ const (
...
@@ -32,6 +36,10 @@ const (
testClaudeAPIURL
=
"https://api.anthropic.com/v1/messages"
testClaudeAPIURL
=
"https://api.anthropic.com/v1/messages"
chatgptCodexAPIURL
=
"https://chatgpt.com/backend-api/codex/responses"
chatgptCodexAPIURL
=
"https://chatgpt.com/backend-api/codex/responses"
soraMeAPIURL
=
"https://sora.chatgpt.com/backend/me"
// Sora 用户信息接口,用于测试连接
soraMeAPIURL
=
"https://sora.chatgpt.com/backend/me"
// Sora 用户信息接口,用于测试连接
soraBillingAPIURL
=
"https://sora.chatgpt.com/backend/billing/subscriptions"
soraInviteMineURL
=
"https://sora.chatgpt.com/backend/project_y/invite/mine"
soraBootstrapURL
=
"https://sora.chatgpt.com/backend/m/bootstrap"
soraRemainingURL
=
"https://sora.chatgpt.com/backend/nf/check"
)
)
// TestEvent represents a SSE event for account testing
// TestEvent represents a SSE event for account testing
...
@@ -39,6 +47,9 @@ type TestEvent struct {
...
@@ -39,6 +47,9 @@ type TestEvent struct {
Type
string
`json:"type"`
Type
string
`json:"type"`
Text
string
`json:"text,omitempty"`
Text
string
`json:"text,omitempty"`
Model
string
`json:"model,omitempty"`
Model
string
`json:"model,omitempty"`
Status
string
`json:"status,omitempty"`
Code
string
`json:"code,omitempty"`
Data
any
`json:"data,omitempty"`
Success
bool
`json:"success,omitempty"`
Success
bool
`json:"success,omitempty"`
Error
string
`json:"error,omitempty"`
Error
string
`json:"error,omitempty"`
}
}
...
@@ -50,8 +61,13 @@ type AccountTestService struct {
...
@@ -50,8 +61,13 @@ type AccountTestService struct {
antigravityGatewayService
*
AntigravityGatewayService
antigravityGatewayService
*
AntigravityGatewayService
httpUpstream
HTTPUpstream
httpUpstream
HTTPUpstream
cfg
*
config
.
Config
cfg
*
config
.
Config
soraTestGuardMu
sync
.
Mutex
soraTestLastRun
map
[
int64
]
time
.
Time
soraTestCooldown
time
.
Duration
}
}
const
defaultSoraTestCooldown
=
10
*
time
.
Second
// NewAccountTestService creates a new AccountTestService
// NewAccountTestService creates a new AccountTestService
func
NewAccountTestService
(
func
NewAccountTestService
(
accountRepo
AccountRepository
,
accountRepo
AccountRepository
,
...
@@ -66,6 +82,8 @@ func NewAccountTestService(
...
@@ -66,6 +82,8 @@ func NewAccountTestService(
antigravityGatewayService
:
antigravityGatewayService
,
antigravityGatewayService
:
antigravityGatewayService
,
httpUpstream
:
httpUpstream
,
httpUpstream
:
httpUpstream
,
cfg
:
cfg
,
cfg
:
cfg
,
soraTestLastRun
:
make
(
map
[
int64
]
time
.
Time
),
soraTestCooldown
:
defaultSoraTestCooldown
,
}
}
}
}
...
@@ -467,13 +485,129 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
...
@@ -467,13 +485,129 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
return
s
.
processGeminiStream
(
c
,
resp
.
Body
)
return
s
.
processGeminiStream
(
c
,
resp
.
Body
)
}
}
type
soraProbeStep
struct
{
Name
string
`json:"name"`
Status
string
`json:"status"`
HTTPStatus
int
`json:"http_status,omitempty"`
ErrorCode
string
`json:"error_code,omitempty"`
Message
string
`json:"message,omitempty"`
}
type
soraProbeSummary
struct
{
Status
string
`json:"status"`
Steps
[]
soraProbeStep
`json:"steps"`
}
type
soraProbeRecorder
struct
{
steps
[]
soraProbeStep
}
func
(
r
*
soraProbeRecorder
)
addStep
(
name
,
status
string
,
httpStatus
int
,
errorCode
,
message
string
)
{
r
.
steps
=
append
(
r
.
steps
,
soraProbeStep
{
Name
:
name
,
Status
:
status
,
HTTPStatus
:
httpStatus
,
ErrorCode
:
strings
.
TrimSpace
(
errorCode
),
Message
:
strings
.
TrimSpace
(
message
),
})
}
func
(
r
*
soraProbeRecorder
)
finalize
()
soraProbeSummary
{
meSuccess
:=
false
partial
:=
false
for
_
,
step
:=
range
r
.
steps
{
if
step
.
Name
==
"me"
{
meSuccess
=
strings
.
EqualFold
(
step
.
Status
,
"success"
)
continue
}
if
strings
.
EqualFold
(
step
.
Status
,
"failed"
)
{
partial
=
true
}
}
status
:=
"success"
if
!
meSuccess
{
status
=
"failed"
}
else
if
partial
{
status
=
"partial_success"
}
return
soraProbeSummary
{
Status
:
status
,
Steps
:
append
([]
soraProbeStep
(
nil
),
r
.
steps
...
),
}
}
func
(
s
*
AccountTestService
)
emitSoraProbeSummary
(
c
*
gin
.
Context
,
rec
*
soraProbeRecorder
)
{
if
rec
==
nil
{
return
}
summary
:=
rec
.
finalize
()
code
:=
""
for
_
,
step
:=
range
summary
.
Steps
{
if
strings
.
EqualFold
(
step
.
Status
,
"failed"
)
&&
strings
.
TrimSpace
(
step
.
ErrorCode
)
!=
""
{
code
=
step
.
ErrorCode
break
}
}
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"sora_test_result"
,
Status
:
summary
.
Status
,
Code
:
code
,
Data
:
summary
,
})
}
func
(
s
*
AccountTestService
)
acquireSoraTestPermit
(
accountID
int64
)
(
time
.
Duration
,
bool
)
{
if
accountID
<=
0
{
return
0
,
true
}
s
.
soraTestGuardMu
.
Lock
()
defer
s
.
soraTestGuardMu
.
Unlock
()
if
s
.
soraTestLastRun
==
nil
{
s
.
soraTestLastRun
=
make
(
map
[
int64
]
time
.
Time
)
}
cooldown
:=
s
.
soraTestCooldown
if
cooldown
<=
0
{
cooldown
=
defaultSoraTestCooldown
}
now
:=
time
.
Now
()
if
lastRun
,
ok
:=
s
.
soraTestLastRun
[
accountID
];
ok
{
elapsed
:=
now
.
Sub
(
lastRun
)
if
elapsed
<
cooldown
{
return
cooldown
-
elapsed
,
false
}
}
s
.
soraTestLastRun
[
accountID
]
=
now
return
0
,
true
}
func
ceilSeconds
(
d
time
.
Duration
)
int
{
if
d
<=
0
{
return
1
}
sec
:=
int
(
d
/
time
.
Second
)
if
d
%
time
.
Second
!=
0
{
sec
++
}
if
sec
<
1
{
sec
=
1
}
return
sec
}
// testSoraAccountConnection 测试 Sora 账号的连接
// testSoraAccountConnection 测试 Sora 账号的连接
// 调用 /backend/me 接口验证 access_token 有效性(不需要 Sentinel Token)
// 调用 /backend/me 接口验证 access_token 有效性(不需要 Sentinel Token)
func
(
s
*
AccountTestService
)
testSoraAccountConnection
(
c
*
gin
.
Context
,
account
*
Account
)
error
{
func
(
s
*
AccountTestService
)
testSoraAccountConnection
(
c
*
gin
.
Context
,
account
*
Account
)
error
{
ctx
:=
c
.
Request
.
Context
()
ctx
:=
c
.
Request
.
Context
()
recorder
:=
&
soraProbeRecorder
{}
authToken
:=
account
.
GetCredential
(
"access_token"
)
authToken
:=
account
.
GetCredential
(
"access_token"
)
if
authToken
==
""
{
if
authToken
==
""
{
recorder
.
addStep
(
"me"
,
"failed"
,
http
.
StatusUnauthorized
,
"missing_access_token"
,
"No access token available"
)
s
.
emitSoraProbeSummary
(
c
,
recorder
)
return
s
.
sendErrorAndEnd
(
c
,
"No access token available"
)
return
s
.
sendErrorAndEnd
(
c
,
"No access token available"
)
}
}
...
@@ -484,11 +618,20 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
...
@@ -484,11 +618,20 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
c
.
Writer
.
Header
()
.
Set
(
"X-Accel-Buffering"
,
"no"
)
c
.
Writer
.
Header
()
.
Set
(
"X-Accel-Buffering"
,
"no"
)
c
.
Writer
.
Flush
()
c
.
Writer
.
Flush
()
if
wait
,
ok
:=
s
.
acquireSoraTestPermit
(
account
.
ID
);
!
ok
{
msg
:=
fmt
.
Sprintf
(
"Sora 账号测试过于频繁,请 %d 秒后重试"
,
ceilSeconds
(
wait
))
recorder
.
addStep
(
"rate_limit"
,
"failed"
,
http
.
StatusTooManyRequests
,
"test_rate_limited"
,
msg
)
s
.
emitSoraProbeSummary
(
c
,
recorder
)
return
s
.
sendErrorAndEnd
(
c
,
msg
)
}
// Send test_start event
// Send test_start event
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"test_start"
,
Model
:
"sora"
})
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"test_start"
,
Model
:
"sora"
})
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
"GET"
,
soraMeAPIURL
,
nil
)
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
"GET"
,
soraMeAPIURL
,
nil
)
if
err
!=
nil
{
if
err
!=
nil
{
recorder
.
addStep
(
"me"
,
"failed"
,
0
,
"request_build_failed"
,
err
.
Error
())
s
.
emitSoraProbeSummary
(
c
,
recorder
)
return
s
.
sendErrorAndEnd
(
c
,
"Failed to create request"
)
return
s
.
sendErrorAndEnd
(
c
,
"Failed to create request"
)
}
}
...
@@ -496,15 +639,21 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
...
@@ -496,15 +639,21 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
req
.
Header
.
Set
(
"Authorization"
,
"Bearer "
+
authToken
)
req
.
Header
.
Set
(
"Authorization"
,
"Bearer "
+
authToken
)
req
.
Header
.
Set
(
"User-Agent"
,
"Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)"
)
req
.
Header
.
Set
(
"User-Agent"
,
"Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)"
)
req
.
Header
.
Set
(
"Accept"
,
"application/json"
)
req
.
Header
.
Set
(
"Accept"
,
"application/json"
)
req
.
Header
.
Set
(
"Accept-Language"
,
"en-US,en;q=0.9"
)
req
.
Header
.
Set
(
"Origin"
,
"https://sora.chatgpt.com"
)
req
.
Header
.
Set
(
"Referer"
,
"https://sora.chatgpt.com/"
)
// Get proxy URL
// Get proxy URL
proxyURL
:=
""
proxyURL
:=
""
if
account
.
ProxyID
!=
nil
&&
account
.
Proxy
!=
nil
{
if
account
.
ProxyID
!=
nil
&&
account
.
Proxy
!=
nil
{
proxyURL
=
account
.
Proxy
.
URL
()
proxyURL
=
account
.
Proxy
.
URL
()
}
}
enableSoraTLSFingerprint
:=
s
.
shouldEnableSoraTLSFingerprint
()
resp
,
err
:=
s
.
httpUpstream
.
DoWithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
account
.
Is
TLSFingerprint
Enabled
()
)
resp
,
err
:=
s
.
httpUpstream
.
DoWithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
enableSora
TLSFingerprint
)
if
err
!=
nil
{
if
err
!=
nil
{
recorder
.
addStep
(
"me"
,
"failed"
,
0
,
"network_error"
,
err
.
Error
())
s
.
emitSoraProbeSummary
(
c
,
recorder
)
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Request failed: %s"
,
err
.
Error
()))
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Request failed: %s"
,
err
.
Error
()))
}
}
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
...
@@ -512,8 +661,33 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
...
@@ -512,8 +661,33 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
body
,
_
:=
io
.
ReadAll
(
resp
.
Body
)
body
,
_
:=
io
.
ReadAll
(
resp
.
Body
)
if
resp
.
StatusCode
!=
http
.
StatusOK
{
if
resp
.
StatusCode
!=
http
.
StatusOK
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Sora API returned %d: %s"
,
resp
.
StatusCode
,
string
(
body
)))
if
isCloudflareChallengeResponse
(
resp
.
StatusCode
,
resp
.
Header
,
body
)
{
recorder
.
addStep
(
"me"
,
"failed"
,
resp
.
StatusCode
,
"cf_challenge"
,
"Cloudflare challenge detected"
)
s
.
emitSoraProbeSummary
(
c
,
recorder
)
s
.
logSoraCloudflareChallenge
(
account
,
proxyURL
,
soraMeAPIURL
,
resp
.
Header
,
body
)
return
s
.
sendErrorAndEnd
(
c
,
formatCloudflareChallengeMessage
(
fmt
.
Sprintf
(
"Sora request blocked by Cloudflare challenge (HTTP %d). Please switch to a clean proxy/network and retry."
,
resp
.
StatusCode
),
resp
.
Header
,
body
))
}
upstreamCode
,
upstreamMessage
:=
soraerror
.
ExtractUpstreamErrorCodeAndMessage
(
body
)
switch
{
case
resp
.
StatusCode
==
http
.
StatusUnauthorized
&&
strings
.
EqualFold
(
upstreamCode
,
"token_invalidated"
)
:
recorder
.
addStep
(
"me"
,
"failed"
,
resp
.
StatusCode
,
"token_invalidated"
,
"Sora token invalidated"
)
s
.
emitSoraProbeSummary
(
c
,
recorder
)
return
s
.
sendErrorAndEnd
(
c
,
"Sora token 已失效(token_invalidated),请重新授权账号"
)
case
strings
.
EqualFold
(
upstreamCode
,
"unsupported_country_code"
)
:
recorder
.
addStep
(
"me"
,
"failed"
,
resp
.
StatusCode
,
"unsupported_country_code"
,
"Sora is unavailable in current egress region"
)
s
.
emitSoraProbeSummary
(
c
,
recorder
)
return
s
.
sendErrorAndEnd
(
c
,
"Sora 在当前网络出口地区不可用(unsupported_country_code),请切换到支持地区后重试"
)
case
strings
.
TrimSpace
(
upstreamMessage
)
!=
""
:
recorder
.
addStep
(
"me"
,
"failed"
,
resp
.
StatusCode
,
upstreamCode
,
upstreamMessage
)
s
.
emitSoraProbeSummary
(
c
,
recorder
)
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Sora API returned %d: %s"
,
resp
.
StatusCode
,
upstreamMessage
))
default
:
recorder
.
addStep
(
"me"
,
"failed"
,
resp
.
StatusCode
,
upstreamCode
,
"Sora me endpoint failed"
)
s
.
emitSoraProbeSummary
(
c
,
recorder
)
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Sora API returned %d: %s"
,
resp
.
StatusCode
,
truncateSoraErrorBody
(
body
,
512
)))
}
}
}
recorder
.
addStep
(
"me"
,
"success"
,
resp
.
StatusCode
,
""
,
"me endpoint ok"
)
// 解析 /me 响应,提取用户信息
// 解析 /me 响应,提取用户信息
var
meResp
map
[
string
]
any
var
meResp
map
[
string
]
any
...
@@ -531,10 +705,384 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
...
@@ -531,10 +705,384 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
info
})
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
info
})
}
}
// 追加轻量能力检查:订阅信息查询(失败仅告警,不中断连接测试)
subReq
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
"GET"
,
soraBillingAPIURL
,
nil
)
if
err
==
nil
{
subReq
.
Header
.
Set
(
"Authorization"
,
"Bearer "
+
authToken
)
subReq
.
Header
.
Set
(
"User-Agent"
,
"Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)"
)
subReq
.
Header
.
Set
(
"Accept"
,
"application/json"
)
subReq
.
Header
.
Set
(
"Accept-Language"
,
"en-US,en;q=0.9"
)
subReq
.
Header
.
Set
(
"Origin"
,
"https://sora.chatgpt.com"
)
subReq
.
Header
.
Set
(
"Referer"
,
"https://sora.chatgpt.com/"
)
subResp
,
subErr
:=
s
.
httpUpstream
.
DoWithTLS
(
subReq
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
enableSoraTLSFingerprint
)
if
subErr
!=
nil
{
recorder
.
addStep
(
"subscription"
,
"failed"
,
0
,
"network_error"
,
subErr
.
Error
())
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
fmt
.
Sprintf
(
"Subscription check skipped: %s"
,
subErr
.
Error
())})
}
else
{
subBody
,
_
:=
io
.
ReadAll
(
subResp
.
Body
)
_
=
subResp
.
Body
.
Close
()
if
subResp
.
StatusCode
==
http
.
StatusOK
{
recorder
.
addStep
(
"subscription"
,
"success"
,
subResp
.
StatusCode
,
""
,
"subscription endpoint ok"
)
if
summary
:=
parseSoraSubscriptionSummary
(
subBody
);
summary
!=
""
{
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
summary
})
}
else
{
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
"Subscription check OK"
})
}
}
else
{
if
isCloudflareChallengeResponse
(
subResp
.
StatusCode
,
subResp
.
Header
,
subBody
)
{
recorder
.
addStep
(
"subscription"
,
"failed"
,
subResp
.
StatusCode
,
"cf_challenge"
,
"Cloudflare challenge detected"
)
s
.
logSoraCloudflareChallenge
(
account
,
proxyURL
,
soraBillingAPIURL
,
subResp
.
Header
,
subBody
)
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
formatCloudflareChallengeMessage
(
fmt
.
Sprintf
(
"Subscription check blocked by Cloudflare challenge (HTTP %d)"
,
subResp
.
StatusCode
),
subResp
.
Header
,
subBody
)})
}
else
{
upstreamCode
,
upstreamMessage
:=
soraerror
.
ExtractUpstreamErrorCodeAndMessage
(
subBody
)
recorder
.
addStep
(
"subscription"
,
"failed"
,
subResp
.
StatusCode
,
upstreamCode
,
upstreamMessage
)
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
fmt
.
Sprintf
(
"Subscription check returned %d"
,
subResp
.
StatusCode
)})
}
}
}
}
// 追加 Sora2 能力探测(对齐 sora2api 的测试思路):邀请码 + 剩余额度。
s
.
testSora2Capabilities
(
c
,
ctx
,
account
,
authToken
,
proxyURL
,
enableSoraTLSFingerprint
,
recorder
)
s
.
emitSoraProbeSummary
(
c
,
recorder
)
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"test_complete"
,
Success
:
true
})
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"test_complete"
,
Success
:
true
})
return
nil
return
nil
}
}
func
(
s
*
AccountTestService
)
testSora2Capabilities
(
c
*
gin
.
Context
,
ctx
context
.
Context
,
account
*
Account
,
authToken
string
,
proxyURL
string
,
enableTLSFingerprint
bool
,
recorder
*
soraProbeRecorder
,
)
{
inviteStatus
,
inviteHeader
,
inviteBody
,
err
:=
s
.
fetchSoraTestEndpoint
(
ctx
,
account
,
authToken
,
soraInviteMineURL
,
proxyURL
,
enableTLSFingerprint
,
)
if
err
!=
nil
{
if
recorder
!=
nil
{
recorder
.
addStep
(
"sora2_invite"
,
"failed"
,
0
,
"network_error"
,
err
.
Error
())
}
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
fmt
.
Sprintf
(
"Sora2 invite check skipped: %s"
,
err
.
Error
())})
return
}
if
inviteStatus
==
http
.
StatusUnauthorized
{
bootstrapStatus
,
_
,
_
,
bootstrapErr
:=
s
.
fetchSoraTestEndpoint
(
ctx
,
account
,
authToken
,
soraBootstrapURL
,
proxyURL
,
enableTLSFingerprint
,
)
if
bootstrapErr
==
nil
&&
bootstrapStatus
==
http
.
StatusOK
{
if
recorder
!=
nil
{
recorder
.
addStep
(
"sora2_bootstrap"
,
"success"
,
bootstrapStatus
,
""
,
"bootstrap endpoint ok"
)
}
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
"Sora2 bootstrap OK, retry invite check"
})
inviteStatus
,
inviteHeader
,
inviteBody
,
err
=
s
.
fetchSoraTestEndpoint
(
ctx
,
account
,
authToken
,
soraInviteMineURL
,
proxyURL
,
enableTLSFingerprint
,
)
if
err
!=
nil
{
if
recorder
!=
nil
{
recorder
.
addStep
(
"sora2_invite"
,
"failed"
,
0
,
"network_error"
,
err
.
Error
())
}
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
fmt
.
Sprintf
(
"Sora2 invite retry failed: %s"
,
err
.
Error
())})
return
}
}
else
if
recorder
!=
nil
{
code
:=
""
msg
:=
""
if
bootstrapErr
!=
nil
{
code
=
"network_error"
msg
=
bootstrapErr
.
Error
()
}
recorder
.
addStep
(
"sora2_bootstrap"
,
"failed"
,
bootstrapStatus
,
code
,
msg
)
}
}
if
inviteStatus
!=
http
.
StatusOK
{
if
isCloudflareChallengeResponse
(
inviteStatus
,
inviteHeader
,
inviteBody
)
{
if
recorder
!=
nil
{
recorder
.
addStep
(
"sora2_invite"
,
"failed"
,
inviteStatus
,
"cf_challenge"
,
"Cloudflare challenge detected"
)
}
s
.
logSoraCloudflareChallenge
(
account
,
proxyURL
,
soraInviteMineURL
,
inviteHeader
,
inviteBody
)
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
formatCloudflareChallengeMessage
(
fmt
.
Sprintf
(
"Sora2 invite check blocked by Cloudflare challenge (HTTP %d)"
,
inviteStatus
),
inviteHeader
,
inviteBody
)})
return
}
upstreamCode
,
upstreamMessage
:=
soraerror
.
ExtractUpstreamErrorCodeAndMessage
(
inviteBody
)
if
recorder
!=
nil
{
recorder
.
addStep
(
"sora2_invite"
,
"failed"
,
inviteStatus
,
upstreamCode
,
upstreamMessage
)
}
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
fmt
.
Sprintf
(
"Sora2 invite check returned %d"
,
inviteStatus
)})
return
}
if
recorder
!=
nil
{
recorder
.
addStep
(
"sora2_invite"
,
"success"
,
inviteStatus
,
""
,
"invite endpoint ok"
)
}
if
summary
:=
parseSoraInviteSummary
(
inviteBody
);
summary
!=
""
{
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
summary
})
}
else
{
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
"Sora2 invite check OK"
})
}
remainingStatus
,
remainingHeader
,
remainingBody
,
remainingErr
:=
s
.
fetchSoraTestEndpoint
(
ctx
,
account
,
authToken
,
soraRemainingURL
,
proxyURL
,
enableTLSFingerprint
,
)
if
remainingErr
!=
nil
{
if
recorder
!=
nil
{
recorder
.
addStep
(
"sora2_remaining"
,
"failed"
,
0
,
"network_error"
,
remainingErr
.
Error
())
}
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
fmt
.
Sprintf
(
"Sora2 remaining check skipped: %s"
,
remainingErr
.
Error
())})
return
}
if
remainingStatus
!=
http
.
StatusOK
{
if
isCloudflareChallengeResponse
(
remainingStatus
,
remainingHeader
,
remainingBody
)
{
if
recorder
!=
nil
{
recorder
.
addStep
(
"sora2_remaining"
,
"failed"
,
remainingStatus
,
"cf_challenge"
,
"Cloudflare challenge detected"
)
}
s
.
logSoraCloudflareChallenge
(
account
,
proxyURL
,
soraRemainingURL
,
remainingHeader
,
remainingBody
)
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
formatCloudflareChallengeMessage
(
fmt
.
Sprintf
(
"Sora2 remaining check blocked by Cloudflare challenge (HTTP %d)"
,
remainingStatus
),
remainingHeader
,
remainingBody
)})
return
}
upstreamCode
,
upstreamMessage
:=
soraerror
.
ExtractUpstreamErrorCodeAndMessage
(
remainingBody
)
if
recorder
!=
nil
{
recorder
.
addStep
(
"sora2_remaining"
,
"failed"
,
remainingStatus
,
upstreamCode
,
upstreamMessage
)
}
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
fmt
.
Sprintf
(
"Sora2 remaining check returned %d"
,
remainingStatus
)})
return
}
if
recorder
!=
nil
{
recorder
.
addStep
(
"sora2_remaining"
,
"success"
,
remainingStatus
,
""
,
"remaining endpoint ok"
)
}
if
summary
:=
parseSoraRemainingSummary
(
remainingBody
);
summary
!=
""
{
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
summary
})
}
else
{
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
"Sora2 remaining check OK"
})
}
}
func
(
s
*
AccountTestService
)
fetchSoraTestEndpoint
(
ctx
context
.
Context
,
account
*
Account
,
authToken
string
,
url
string
,
proxyURL
string
,
enableTLSFingerprint
bool
,
)
(
int
,
http
.
Header
,
[]
byte
,
error
)
{
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
"GET"
,
url
,
nil
)
if
err
!=
nil
{
return
0
,
nil
,
nil
,
err
}
req
.
Header
.
Set
(
"Authorization"
,
"Bearer "
+
authToken
)
req
.
Header
.
Set
(
"User-Agent"
,
"Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)"
)
req
.
Header
.
Set
(
"Accept"
,
"application/json"
)
req
.
Header
.
Set
(
"Accept-Language"
,
"en-US,en;q=0.9"
)
req
.
Header
.
Set
(
"Origin"
,
"https://sora.chatgpt.com"
)
req
.
Header
.
Set
(
"Referer"
,
"https://sora.chatgpt.com/"
)
resp
,
err
:=
s
.
httpUpstream
.
DoWithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
enableTLSFingerprint
)
if
err
!=
nil
{
return
0
,
nil
,
nil
,
err
}
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
body
,
readErr
:=
io
.
ReadAll
(
resp
.
Body
)
if
readErr
!=
nil
{
return
resp
.
StatusCode
,
resp
.
Header
,
nil
,
readErr
}
return
resp
.
StatusCode
,
resp
.
Header
,
body
,
nil
}
func
parseSoraSubscriptionSummary
(
body
[]
byte
)
string
{
var
subResp
struct
{
Data
[]
struct
{
Plan
struct
{
ID
string
`json:"id"`
Title
string
`json:"title"`
}
`json:"plan"`
EndTS
string
`json:"end_ts"`
}
`json:"data"`
}
if
err
:=
json
.
Unmarshal
(
body
,
&
subResp
);
err
!=
nil
{
return
""
}
if
len
(
subResp
.
Data
)
==
0
{
return
""
}
first
:=
subResp
.
Data
[
0
]
parts
:=
make
([]
string
,
0
,
3
)
if
first
.
Plan
.
Title
!=
""
{
parts
=
append
(
parts
,
first
.
Plan
.
Title
)
}
if
first
.
Plan
.
ID
!=
""
{
parts
=
append
(
parts
,
first
.
Plan
.
ID
)
}
if
first
.
EndTS
!=
""
{
parts
=
append
(
parts
,
"end="
+
first
.
EndTS
)
}
if
len
(
parts
)
==
0
{
return
""
}
return
"Subscription: "
+
strings
.
Join
(
parts
,
" | "
)
}
func
parseSoraInviteSummary
(
body
[]
byte
)
string
{
var
inviteResp
struct
{
InviteCode
string
`json:"invite_code"`
RedeemedCount
int64
`json:"redeemed_count"`
TotalCount
int64
`json:"total_count"`
}
if
err
:=
json
.
Unmarshal
(
body
,
&
inviteResp
);
err
!=
nil
{
return
""
}
parts
:=
[]
string
{
"Sora2: supported"
}
if
inviteResp
.
InviteCode
!=
""
{
parts
=
append
(
parts
,
"invite="
+
inviteResp
.
InviteCode
)
}
if
inviteResp
.
TotalCount
>
0
{
parts
=
append
(
parts
,
fmt
.
Sprintf
(
"used=%d/%d"
,
inviteResp
.
RedeemedCount
,
inviteResp
.
TotalCount
))
}
return
strings
.
Join
(
parts
,
" | "
)
}
func
parseSoraRemainingSummary
(
body
[]
byte
)
string
{
var
remainingResp
struct
{
RateLimitAndCreditBalance
struct
{
EstimatedNumVideosRemaining
int64
`json:"estimated_num_videos_remaining"`
RateLimitReached
bool
`json:"rate_limit_reached"`
AccessResetsInSeconds
int64
`json:"access_resets_in_seconds"`
}
`json:"rate_limit_and_credit_balance"`
}
if
err
:=
json
.
Unmarshal
(
body
,
&
remainingResp
);
err
!=
nil
{
return
""
}
info
:=
remainingResp
.
RateLimitAndCreditBalance
parts
:=
[]
string
{
fmt
.
Sprintf
(
"Sora2 remaining: %d"
,
info
.
EstimatedNumVideosRemaining
)}
if
info
.
RateLimitReached
{
parts
=
append
(
parts
,
"rate_limited=true"
)
}
if
info
.
AccessResetsInSeconds
>
0
{
parts
=
append
(
parts
,
fmt
.
Sprintf
(
"reset_in=%ds"
,
info
.
AccessResetsInSeconds
))
}
return
strings
.
Join
(
parts
,
" | "
)
}
func
(
s
*
AccountTestService
)
shouldEnableSoraTLSFingerprint
()
bool
{
if
s
==
nil
||
s
.
cfg
==
nil
{
return
true
}
return
!
s
.
cfg
.
Sora
.
Client
.
DisableTLSFingerprint
}
func
isCloudflareChallengeResponse
(
statusCode
int
,
headers
http
.
Header
,
body
[]
byte
)
bool
{
return
soraerror
.
IsCloudflareChallengeResponse
(
statusCode
,
headers
,
body
)
}
func
formatCloudflareChallengeMessage
(
base
string
,
headers
http
.
Header
,
body
[]
byte
)
string
{
return
soraerror
.
FormatCloudflareChallengeMessage
(
base
,
headers
,
body
)
}
func
extractCloudflareRayID
(
headers
http
.
Header
,
body
[]
byte
)
string
{
return
soraerror
.
ExtractCloudflareRayID
(
headers
,
body
)
}
func
extractSoraEgressIPHint
(
headers
http
.
Header
)
string
{
if
headers
==
nil
{
return
"unknown"
}
candidates
:=
[]
string
{
"x-openai-public-ip"
,
"x-envoy-external-address"
,
"cf-connecting-ip"
,
"x-forwarded-for"
,
}
for
_
,
key
:=
range
candidates
{
if
value
:=
strings
.
TrimSpace
(
headers
.
Get
(
key
));
value
!=
""
{
return
value
}
}
return
"unknown"
}
func
sanitizeProxyURLForLog
(
raw
string
)
string
{
raw
=
strings
.
TrimSpace
(
raw
)
if
raw
==
""
{
return
""
}
u
,
err
:=
url
.
Parse
(
raw
)
if
err
!=
nil
{
return
"<invalid_proxy_url>"
}
if
u
.
User
!=
nil
{
u
.
User
=
nil
}
return
u
.
String
()
}
func
endpointPathForLog
(
endpoint
string
)
string
{
parsed
,
err
:=
url
.
Parse
(
strings
.
TrimSpace
(
endpoint
))
if
err
!=
nil
||
parsed
.
Path
==
""
{
return
endpoint
}
return
parsed
.
Path
}
func
(
s
*
AccountTestService
)
logSoraCloudflareChallenge
(
account
*
Account
,
proxyURL
,
endpoint
string
,
headers
http
.
Header
,
body
[]
byte
)
{
accountID
:=
int64
(
0
)
platform
:=
""
proxyID
:=
"none"
if
account
!=
nil
{
accountID
=
account
.
ID
platform
=
account
.
Platform
if
account
.
ProxyID
!=
nil
{
proxyID
=
fmt
.
Sprintf
(
"%d"
,
*
account
.
ProxyID
)
}
}
cfRay
:=
extractCloudflareRayID
(
headers
,
body
)
if
cfRay
==
""
{
cfRay
=
"unknown"
}
log
.
Printf
(
"[SoraCFChallenge] account_id=%d platform=%s endpoint=%s path=%s proxy_id=%s proxy_url=%s cf_ray=%s egress_ip_hint=%s"
,
accountID
,
platform
,
endpoint
,
endpointPathForLog
(
endpoint
),
proxyID
,
sanitizeProxyURLForLog
(
proxyURL
),
cfRay
,
extractSoraEgressIPHint
(
headers
),
)
}
func
truncateSoraErrorBody
(
body
[]
byte
,
max
int
)
string
{
return
soraerror
.
TruncateBody
(
body
,
max
)
}
// testAntigravityAccountConnection tests an Antigravity account's connection
// testAntigravityAccountConnection tests an Antigravity account's connection
// 支持 Claude 和 Gemini 两种协议,使用非流式请求
// 支持 Claude 和 Gemini 两种协议,使用非流式请求
func
(
s
*
AccountTestService
)
testAntigravityAccountConnection
(
c
*
gin
.
Context
,
account
*
Account
,
modelID
string
)
error
{
func
(
s
*
AccountTestService
)
testAntigravityAccountConnection
(
c
*
gin
.
Context
,
account
*
Account
,
modelID
string
)
error
{
...
...
Prev
1
2
3
4
5
6
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