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
618a614c
Commit
618a614c
authored
Jan 31, 2026
by
yangjianbo
Browse files
feat(Sora): 完成Sora网关接入与媒体能力
新增 Sora 网关路由、账号调度与同步服务\n补充媒体代理与签名 URL、模型列表动态拉取\n完善计费配置、前端支持与相关测试
parent
99dc3b59
Changes
67
Hide whitespace changes
Inline
Side-by-side
backend/internal/service/openai_token_provider.go
View file @
618a614c
...
...
@@ -41,8 +41,8 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou
if
account
==
nil
{
return
""
,
errors
.
New
(
"account is nil"
)
}
if
account
.
Platform
!=
PlatformOpenAI
||
account
.
Type
!=
AccountTypeOAuth
{
return
""
,
errors
.
New
(
"not an openai oauth account"
)
if
(
account
.
Platform
!=
PlatformOpenAI
&&
account
.
Platform
!=
PlatformSora
)
||
account
.
Type
!=
AccountTypeOAuth
{
return
""
,
errors
.
New
(
"not an openai
/sora
oauth account"
)
}
cacheKey
:=
OpenAITokenCacheKey
(
account
)
...
...
@@ -157,7 +157,7 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou
}
}
accessToken
:=
account
.
Get
OpenAIA
ccess
T
oken
(
)
accessToken
:=
account
.
Get
Credential
(
"a
ccess
_t
oken
"
)
if
strings
.
TrimSpace
(
accessToken
)
==
""
{
return
""
,
errors
.
New
(
"access_token not found in credentials"
)
}
...
...
backend/internal/service/openai_token_provider_test.go
View file @
618a614c
...
...
@@ -375,7 +375,7 @@ func TestOpenAITokenProvider_WrongPlatform(t *testing.T) {
token
,
err
:=
provider
.
GetAccessToken
(
context
.
Background
(),
account
)
require
.
Error
(
t
,
err
)
require
.
Contains
(
t
,
err
.
Error
(),
"not an openai oauth account"
)
require
.
Contains
(
t
,
err
.
Error
(),
"not an openai
/sora
oauth account"
)
require
.
Empty
(
t
,
token
)
}
...
...
@@ -389,7 +389,7 @@ func TestOpenAITokenProvider_WrongAccountType(t *testing.T) {
token
,
err
:=
provider
.
GetAccessToken
(
context
.
Background
(),
account
)
require
.
Error
(
t
,
err
)
require
.
Contains
(
t
,
err
.
Error
(),
"not an openai oauth account"
)
require
.
Contains
(
t
,
err
.
Error
(),
"not an openai
/sora
oauth account"
)
require
.
Empty
(
t
,
token
)
}
...
...
backend/internal/service/sora2api_service.go
0 → 100644
View file @
618a614c
package
service
import
(
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
)
// Sora2APIModel represents a model entry returned by sora2api.
type
Sora2APIModel
struct
{
ID
string
`json:"id"`
Object
string
`json:"object"`
OwnedBy
string
`json:"owned_by,omitempty"`
Description
string
`json:"description,omitempty"`
}
// Sora2APIModelList represents /v1/models response.
type
Sora2APIModelList
struct
{
Object
string
`json:"object"`
Data
[]
Sora2APIModel
`json:"data"`
}
// Sora2APIImportTokenItem mirrors sora2api ImportTokenItem.
type
Sora2APIImportTokenItem
struct
{
Email
string
`json:"email"`
AccessToken
string
`json:"access_token,omitempty"`
SessionToken
string
`json:"session_token,omitempty"`
RefreshToken
string
`json:"refresh_token,omitempty"`
ClientID
string
`json:"client_id,omitempty"`
ProxyURL
string
`json:"proxy_url,omitempty"`
Remark
string
`json:"remark,omitempty"`
IsActive
bool
`json:"is_active"`
ImageEnabled
bool
`json:"image_enabled"`
VideoEnabled
bool
`json:"video_enabled"`
ImageConcurrency
int
`json:"image_concurrency"`
VideoConcurrency
int
`json:"video_concurrency"`
}
// Sora2APIToken represents minimal fields for admin list.
type
Sora2APIToken
struct
{
ID
int64
`json:"id"`
Email
string
`json:"email"`
Name
string
`json:"name"`
Remark
string
`json:"remark"`
}
// Sora2APIService provides access to sora2api endpoints.
type
Sora2APIService
struct
{
cfg
*
config
.
Config
baseURL
string
apiKey
string
adminUsername
string
adminPassword
string
adminTokenTTL
time
.
Duration
adminTimeout
time
.
Duration
tokenImportMode
string
client
*
http
.
Client
adminClient
*
http
.
Client
adminToken
string
adminTokenAt
time
.
Time
adminMu
sync
.
Mutex
modelCache
[]
Sora2APIModel
modelCacheAt
time
.
Time
modelMu
sync
.
RWMutex
}
func
NewSora2APIService
(
cfg
*
config
.
Config
)
*
Sora2APIService
{
if
cfg
==
nil
{
return
&
Sora2APIService
{}
}
adminTTL
:=
time
.
Duration
(
cfg
.
Sora2API
.
AdminTokenTTLSeconds
)
*
time
.
Second
if
adminTTL
<=
0
{
adminTTL
=
15
*
time
.
Minute
}
adminTimeout
:=
time
.
Duration
(
cfg
.
Sora2API
.
AdminTimeoutSeconds
)
*
time
.
Second
if
adminTimeout
<=
0
{
adminTimeout
=
10
*
time
.
Second
}
return
&
Sora2APIService
{
cfg
:
cfg
,
baseURL
:
strings
.
TrimRight
(
strings
.
TrimSpace
(
cfg
.
Sora2API
.
BaseURL
),
"/"
),
apiKey
:
strings
.
TrimSpace
(
cfg
.
Sora2API
.
APIKey
),
adminUsername
:
strings
.
TrimSpace
(
cfg
.
Sora2API
.
AdminUsername
),
adminPassword
:
strings
.
TrimSpace
(
cfg
.
Sora2API
.
AdminPassword
),
adminTokenTTL
:
adminTTL
,
adminTimeout
:
adminTimeout
,
tokenImportMode
:
strings
.
ToLower
(
strings
.
TrimSpace
(
cfg
.
Sora2API
.
TokenImportMode
)),
client
:
&
http
.
Client
{},
adminClient
:
&
http
.
Client
{
Timeout
:
adminTimeout
},
}
}
func
(
s
*
Sora2APIService
)
Enabled
()
bool
{
return
s
!=
nil
&&
s
.
baseURL
!=
""
&&
s
.
apiKey
!=
""
}
func
(
s
*
Sora2APIService
)
AdminEnabled
()
bool
{
return
s
!=
nil
&&
s
.
baseURL
!=
""
&&
s
.
adminUsername
!=
""
&&
s
.
adminPassword
!=
""
}
func
(
s
*
Sora2APIService
)
buildURL
(
path
string
)
string
{
if
s
.
baseURL
==
""
{
return
path
}
if
strings
.
HasPrefix
(
path
,
"/"
)
{
return
s
.
baseURL
+
path
}
return
s
.
baseURL
+
"/"
+
path
}
// BuildURL 返回完整的 sora2api URL(用于代理媒体)
func
(
s
*
Sora2APIService
)
BuildURL
(
path
string
)
string
{
return
s
.
buildURL
(
path
)
}
func
(
s
*
Sora2APIService
)
NewAPIRequest
(
ctx
context
.
Context
,
method
string
,
path
string
,
body
[]
byte
)
(
*
http
.
Request
,
error
)
{
if
!
s
.
Enabled
()
{
return
nil
,
errors
.
New
(
"sora2api not configured"
)
}
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
method
,
s
.
buildURL
(
path
),
bytes
.
NewReader
(
body
))
if
err
!=
nil
{
return
nil
,
err
}
req
.
Header
.
Set
(
"Authorization"
,
"Bearer "
+
s
.
apiKey
)
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
return
req
,
nil
}
func
(
s
*
Sora2APIService
)
ListModels
(
ctx
context
.
Context
)
([]
Sora2APIModel
,
error
)
{
if
!
s
.
Enabled
()
{
return
nil
,
errors
.
New
(
"sora2api not configured"
)
}
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
http
.
MethodGet
,
s
.
buildURL
(
"/v1/models"
),
nil
)
if
err
!=
nil
{
return
nil
,
err
}
req
.
Header
.
Set
(
"Authorization"
,
"Bearer "
+
s
.
apiKey
)
resp
,
err
:=
s
.
client
.
Do
(
req
)
if
err
!=
nil
{
return
s
.
cachedModelsOnError
(
err
)
}
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
if
resp
.
StatusCode
!=
http
.
StatusOK
{
return
s
.
cachedModelsOnError
(
fmt
.
Errorf
(
"sora2api models status: %d"
,
resp
.
StatusCode
))
}
var
payload
Sora2APIModelList
if
err
:=
json
.
NewDecoder
(
resp
.
Body
)
.
Decode
(
&
payload
);
err
!=
nil
{
return
s
.
cachedModelsOnError
(
err
)
}
models
:=
payload
.
Data
if
s
.
cfg
!=
nil
&&
s
.
cfg
.
Gateway
.
SoraModelFilters
.
HidePromptEnhance
{
filtered
:=
make
([]
Sora2APIModel
,
0
,
len
(
models
))
for
_
,
m
:=
range
models
{
if
strings
.
HasPrefix
(
strings
.
ToLower
(
m
.
ID
),
"prompt-enhance"
)
{
continue
}
filtered
=
append
(
filtered
,
m
)
}
models
=
filtered
}
s
.
modelMu
.
Lock
()
s
.
modelCache
=
models
s
.
modelCacheAt
=
time
.
Now
()
s
.
modelMu
.
Unlock
()
return
models
,
nil
}
func
(
s
*
Sora2APIService
)
cachedModelsOnError
(
err
error
)
([]
Sora2APIModel
,
error
)
{
s
.
modelMu
.
RLock
()
cached
:=
append
([]
Sora2APIModel
(
nil
),
s
.
modelCache
...
)
s
.
modelMu
.
RUnlock
()
if
len
(
cached
)
>
0
{
log
.
Printf
(
"[Sora2API] 模型列表拉取失败,回退缓存: %v"
,
err
)
return
cached
,
nil
}
return
nil
,
err
}
func
(
s
*
Sora2APIService
)
ImportTokens
(
ctx
context
.
Context
,
items
[]
Sora2APIImportTokenItem
)
error
{
if
!
s
.
AdminEnabled
()
{
return
errors
.
New
(
"sora2api admin not configured"
)
}
mode
:=
s
.
tokenImportMode
if
mode
==
""
{
mode
=
"at"
}
payload
:=
map
[
string
]
any
{
"tokens"
:
items
,
"mode"
:
mode
,
}
_
,
err
:=
s
.
doAdminRequest
(
ctx
,
http
.
MethodPost
,
"/api/tokens/import"
,
payload
,
nil
)
return
err
}
func
(
s
*
Sora2APIService
)
ListTokens
(
ctx
context
.
Context
)
([]
Sora2APIToken
,
error
)
{
if
!
s
.
AdminEnabled
()
{
return
nil
,
errors
.
New
(
"sora2api admin not configured"
)
}
var
tokens
[]
Sora2APIToken
_
,
err
:=
s
.
doAdminRequest
(
ctx
,
http
.
MethodGet
,
"/api/tokens"
,
nil
,
&
tokens
)
return
tokens
,
err
}
func
(
s
*
Sora2APIService
)
DisableToken
(
ctx
context
.
Context
,
tokenID
int64
)
error
{
if
!
s
.
AdminEnabled
()
{
return
errors
.
New
(
"sora2api admin not configured"
)
}
path
:=
fmt
.
Sprintf
(
"/api/tokens/%d/disable"
,
tokenID
)
_
,
err
:=
s
.
doAdminRequest
(
ctx
,
http
.
MethodPost
,
path
,
nil
,
nil
)
return
err
}
func
(
s
*
Sora2APIService
)
DeleteToken
(
ctx
context
.
Context
,
tokenID
int64
)
error
{
if
!
s
.
AdminEnabled
()
{
return
errors
.
New
(
"sora2api admin not configured"
)
}
path
:=
fmt
.
Sprintf
(
"/api/tokens/%d"
,
tokenID
)
_
,
err
:=
s
.
doAdminRequest
(
ctx
,
http
.
MethodDelete
,
path
,
nil
,
nil
)
return
err
}
func
(
s
*
Sora2APIService
)
doAdminRequest
(
ctx
context
.
Context
,
method
string
,
path
string
,
body
any
,
out
any
)
(
*
http
.
Response
,
error
)
{
if
!
s
.
AdminEnabled
()
{
return
nil
,
errors
.
New
(
"sora2api admin not configured"
)
}
token
,
err
:=
s
.
getAdminToken
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
resp
,
err
:=
s
.
doAdminRequestWithToken
(
ctx
,
method
,
path
,
token
,
body
,
out
)
if
err
==
nil
&&
resp
!=
nil
&&
resp
.
StatusCode
!=
http
.
StatusUnauthorized
{
return
resp
,
nil
}
if
resp
!=
nil
&&
resp
.
StatusCode
==
http
.
StatusUnauthorized
{
s
.
invalidateAdminToken
()
token
,
err
=
s
.
getAdminToken
(
ctx
)
if
err
!=
nil
{
return
resp
,
err
}
return
s
.
doAdminRequestWithToken
(
ctx
,
method
,
path
,
token
,
body
,
out
)
}
return
resp
,
err
}
func
(
s
*
Sora2APIService
)
doAdminRequestWithToken
(
ctx
context
.
Context
,
method
string
,
path
string
,
token
string
,
body
any
,
out
any
)
(
*
http
.
Response
,
error
)
{
var
reader
*
bytes
.
Reader
if
body
!=
nil
{
buf
,
err
:=
json
.
Marshal
(
body
)
if
err
!=
nil
{
return
nil
,
err
}
reader
=
bytes
.
NewReader
(
buf
)
}
else
{
reader
=
bytes
.
NewReader
(
nil
)
}
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
method
,
s
.
buildURL
(
path
),
reader
)
if
err
!=
nil
{
return
nil
,
err
}
req
.
Header
.
Set
(
"Authorization"
,
"Bearer "
+
token
)
if
body
!=
nil
{
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
}
resp
,
err
:=
s
.
adminClient
.
Do
(
req
)
if
err
!=
nil
{
return
resp
,
err
}
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
if
resp
.
StatusCode
<
200
||
resp
.
StatusCode
>=
300
{
return
resp
,
fmt
.
Errorf
(
"sora2api admin status: %d"
,
resp
.
StatusCode
)
}
if
out
!=
nil
{
if
err
:=
json
.
NewDecoder
(
resp
.
Body
)
.
Decode
(
out
);
err
!=
nil
{
return
resp
,
err
}
}
return
resp
,
nil
}
func
(
s
*
Sora2APIService
)
getAdminToken
(
ctx
context
.
Context
)
(
string
,
error
)
{
s
.
adminMu
.
Lock
()
defer
s
.
adminMu
.
Unlock
()
if
s
.
adminToken
!=
""
&&
time
.
Since
(
s
.
adminTokenAt
)
<
s
.
adminTokenTTL
{
return
s
.
adminToken
,
nil
}
if
!
s
.
AdminEnabled
()
{
return
""
,
errors
.
New
(
"sora2api admin not configured"
)
}
payload
:=
map
[
string
]
string
{
"username"
:
s
.
adminUsername
,
"password"
:
s
.
adminPassword
,
}
buf
,
err
:=
json
.
Marshal
(
payload
)
if
err
!=
nil
{
return
""
,
err
}
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
http
.
MethodPost
,
s
.
buildURL
(
"/api/login"
),
bytes
.
NewReader
(
buf
))
if
err
!=
nil
{
return
""
,
err
}
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
resp
,
err
:=
s
.
adminClient
.
Do
(
req
)
if
err
!=
nil
{
return
""
,
err
}
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
if
resp
.
StatusCode
!=
http
.
StatusOK
{
return
""
,
fmt
.
Errorf
(
"sora2api login failed: %d"
,
resp
.
StatusCode
)
}
var
result
struct
{
Success
bool
`json:"success"`
Token
string
`json:"token"`
Message
string
`json:"message"`
}
if
err
:=
json
.
NewDecoder
(
resp
.
Body
)
.
Decode
(
&
result
);
err
!=
nil
{
return
""
,
err
}
if
!
result
.
Success
||
result
.
Token
==
""
{
if
result
.
Message
==
""
{
result
.
Message
=
"sora2api login failed"
}
return
""
,
errors
.
New
(
result
.
Message
)
}
s
.
adminToken
=
result
.
Token
s
.
adminTokenAt
=
time
.
Now
()
return
result
.
Token
,
nil
}
func
(
s
*
Sora2APIService
)
invalidateAdminToken
()
{
s
.
adminMu
.
Lock
()
defer
s
.
adminMu
.
Unlock
()
s
.
adminToken
=
""
s
.
adminTokenAt
=
time
.
Time
{}
}
backend/internal/service/sora2api_sync_service.go
0 → 100644
View file @
618a614c
package
service
import
(
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Sora2APISyncService 用于同步 Sora 账号到 sora2api token 池
type
Sora2APISyncService
struct
{
sora2api
*
Sora2APIService
accountRepo
AccountRepository
httpClient
*
http
.
Client
}
func
NewSora2APISyncService
(
sora2api
*
Sora2APIService
,
accountRepo
AccountRepository
)
*
Sora2APISyncService
{
return
&
Sora2APISyncService
{
sora2api
:
sora2api
,
accountRepo
:
accountRepo
,
httpClient
:
&
http
.
Client
{
Timeout
:
10
*
time
.
Second
},
}
}
func
(
s
*
Sora2APISyncService
)
Enabled
()
bool
{
return
s
!=
nil
&&
s
.
sora2api
!=
nil
&&
s
.
sora2api
.
AdminEnabled
()
}
// SyncAccount 将 Sora 账号同步到 sora2api(导入或更新)
func
(
s
*
Sora2APISyncService
)
SyncAccount
(
ctx
context
.
Context
,
account
*
Account
)
error
{
if
!
s
.
Enabled
()
{
return
nil
}
if
account
==
nil
||
account
.
Platform
!=
PlatformSora
{
return
nil
}
accessToken
:=
strings
.
TrimSpace
(
account
.
GetCredential
(
"access_token"
))
if
accessToken
==
""
{
return
errors
.
New
(
"sora 账号缺少 access_token"
)
}
email
,
updated
:=
s
.
resolveAccountEmail
(
ctx
,
account
)
if
email
==
""
{
return
errors
.
New
(
"无法解析 Sora 账号邮箱"
)
}
if
updated
&&
s
.
accountRepo
!=
nil
{
if
err
:=
s
.
accountRepo
.
Update
(
ctx
,
account
);
err
!=
nil
{
log
.
Printf
(
"[SoraSync] 更新账号邮箱失败: account_id=%d err=%v"
,
account
.
ID
,
err
)
}
}
item
:=
Sora2APIImportTokenItem
{
Email
:
email
,
AccessToken
:
accessToken
,
SessionToken
:
strings
.
TrimSpace
(
account
.
GetCredential
(
"session_token"
)),
RefreshToken
:
strings
.
TrimSpace
(
account
.
GetCredential
(
"refresh_token"
)),
ClientID
:
strings
.
TrimSpace
(
account
.
GetCredential
(
"client_id"
)),
Remark
:
account
.
Name
,
IsActive
:
account
.
IsActive
()
&&
account
.
Schedulable
,
ImageEnabled
:
true
,
VideoEnabled
:
true
,
ImageConcurrency
:
normalizeSoraConcurrency
(
account
.
Concurrency
),
VideoConcurrency
:
normalizeSoraConcurrency
(
account
.
Concurrency
),
}
if
err
:=
s
.
sora2api
.
ImportTokens
(
ctx
,
[]
Sora2APIImportTokenItem
{
item
});
err
!=
nil
{
return
err
}
return
nil
}
// DisableAccount 禁用 sora2api 中的 token
func
(
s
*
Sora2APISyncService
)
DisableAccount
(
ctx
context
.
Context
,
account
*
Account
)
error
{
if
!
s
.
Enabled
()
{
return
nil
}
if
account
==
nil
||
account
.
Platform
!=
PlatformSora
{
return
nil
}
tokenID
,
err
:=
s
.
resolveTokenID
(
ctx
,
account
)
if
err
!=
nil
{
return
err
}
return
s
.
sora2api
.
DisableToken
(
ctx
,
tokenID
)
}
// DeleteAccount 删除 sora2api 中的 token
func
(
s
*
Sora2APISyncService
)
DeleteAccount
(
ctx
context
.
Context
,
account
*
Account
)
error
{
if
!
s
.
Enabled
()
{
return
nil
}
if
account
==
nil
||
account
.
Platform
!=
PlatformSora
{
return
nil
}
tokenID
,
err
:=
s
.
resolveTokenID
(
ctx
,
account
)
if
err
!=
nil
{
return
err
}
return
s
.
sora2api
.
DeleteToken
(
ctx
,
tokenID
)
}
func
normalizeSoraConcurrency
(
value
int
)
int
{
if
value
<=
0
{
return
-
1
}
return
value
}
func
(
s
*
Sora2APISyncService
)
resolveAccountEmail
(
ctx
context
.
Context
,
account
*
Account
)
(
string
,
bool
)
{
if
account
==
nil
{
return
""
,
false
}
if
email
:=
strings
.
TrimSpace
(
account
.
GetCredential
(
"email"
));
email
!=
""
{
return
email
,
false
}
if
email
:=
strings
.
TrimSpace
(
account
.
GetExtraString
(
"email"
));
email
!=
""
{
if
account
.
Credentials
==
nil
{
account
.
Credentials
=
map
[
string
]
any
{}
}
account
.
Credentials
[
"email"
]
=
email
return
email
,
true
}
if
email
:=
strings
.
TrimSpace
(
account
.
GetExtraString
(
"sora_email"
));
email
!=
""
{
if
account
.
Credentials
==
nil
{
account
.
Credentials
=
map
[
string
]
any
{}
}
account
.
Credentials
[
"email"
]
=
email
return
email
,
true
}
accessToken
:=
strings
.
TrimSpace
(
account
.
GetCredential
(
"access_token"
))
if
accessToken
!=
""
{
if
email
:=
extractEmailFromAccessToken
(
accessToken
);
email
!=
""
{
if
account
.
Credentials
==
nil
{
account
.
Credentials
=
map
[
string
]
any
{}
}
account
.
Credentials
[
"email"
]
=
email
return
email
,
true
}
if
email
:=
s
.
fetchEmailFromSora
(
ctx
,
accessToken
);
email
!=
""
{
if
account
.
Credentials
==
nil
{
account
.
Credentials
=
map
[
string
]
any
{}
}
account
.
Credentials
[
"email"
]
=
email
return
email
,
true
}
}
return
""
,
false
}
func
(
s
*
Sora2APISyncService
)
resolveTokenID
(
ctx
context
.
Context
,
account
*
Account
)
(
int64
,
error
)
{
if
account
==
nil
{
return
0
,
errors
.
New
(
"account is nil"
)
}
if
account
.
Extra
!=
nil
{
if
v
,
ok
:=
account
.
Extra
[
"sora2api_token_id"
];
ok
{
if
id
,
ok
:=
v
.
(
float64
);
ok
&&
id
>
0
{
return
int64
(
id
),
nil
}
if
id
,
ok
:=
v
.
(
int64
);
ok
&&
id
>
0
{
return
id
,
nil
}
if
id
,
ok
:=
v
.
(
int
);
ok
&&
id
>
0
{
return
int64
(
id
),
nil
}
}
}
email
:=
strings
.
TrimSpace
(
account
.
GetCredential
(
"email"
))
if
email
==
""
{
email
,
_
=
s
.
resolveAccountEmail
(
ctx
,
account
)
}
if
email
==
""
{
return
0
,
errors
.
New
(
"sora2api token email missing"
)
}
tokenID
,
err
:=
s
.
findTokenIDByEmail
(
ctx
,
email
)
if
err
!=
nil
{
return
0
,
err
}
return
tokenID
,
nil
}
func
(
s
*
Sora2APISyncService
)
findTokenIDByEmail
(
ctx
context
.
Context
,
email
string
)
(
int64
,
error
)
{
if
!
s
.
Enabled
()
{
return
0
,
errors
.
New
(
"sora2api admin not configured"
)
}
tokens
,
err
:=
s
.
sora2api
.
ListTokens
(
ctx
)
if
err
!=
nil
{
return
0
,
err
}
for
_
,
token
:=
range
tokens
{
if
strings
.
EqualFold
(
strings
.
TrimSpace
(
token
.
Email
),
strings
.
TrimSpace
(
email
))
{
return
token
.
ID
,
nil
}
}
return
0
,
fmt
.
Errorf
(
"sora2api token not found for email: %s"
,
email
)
}
func
extractEmailFromAccessToken
(
accessToken
string
)
string
{
parser
:=
jwt
.
NewParser
(
jwt
.
WithoutClaimsValidation
())
claims
:=
jwt
.
MapClaims
{}
_
,
_
,
err
:=
parser
.
ParseUnverified
(
accessToken
,
claims
)
if
err
!=
nil
{
return
""
}
if
email
,
ok
:=
claims
[
"email"
]
.
(
string
);
ok
&&
strings
.
TrimSpace
(
email
)
!=
""
{
return
email
}
if
profile
,
ok
:=
claims
[
"https://api.openai.com/profile"
]
.
(
map
[
string
]
any
);
ok
{
if
email
,
ok
:=
profile
[
"email"
]
.
(
string
);
ok
&&
strings
.
TrimSpace
(
email
)
!=
""
{
return
email
}
}
return
""
}
func
(
s
*
Sora2APISyncService
)
fetchEmailFromSora
(
ctx
context
.
Context
,
accessToken
string
)
string
{
if
s
.
httpClient
==
nil
{
return
""
}
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
http
.
MethodGet
,
soraMeAPIURL
,
nil
)
if
err
!=
nil
{
return
""
}
req
.
Header
.
Set
(
"Authorization"
,
"Bearer "
+
accessToken
)
req
.
Header
.
Set
(
"User-Agent"
,
"Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)"
)
req
.
Header
.
Set
(
"Accept"
,
"application/json"
)
resp
,
err
:=
s
.
httpClient
.
Do
(
req
)
if
err
!=
nil
{
return
""
}
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
if
resp
.
StatusCode
!=
http
.
StatusOK
{
return
""
}
var
payload
map
[
string
]
any
if
err
:=
json
.
NewDecoder
(
resp
.
Body
)
.
Decode
(
&
payload
);
err
!=
nil
{
return
""
}
if
email
,
ok
:=
payload
[
"email"
]
.
(
string
);
ok
&&
strings
.
TrimSpace
(
email
)
!=
""
{
return
email
}
return
""
}
backend/internal/service/sora_gateway_service.go
0 → 100644
View file @
618a614c
package
service
import
(
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
)
var
soraSSEDataRe
=
regexp
.
MustCompile
(
`^data:\s*`
)
var
soraImageMarkdownRe
=
regexp
.
MustCompile
(
`!\[[^\]]*\]\(([^)]+)\)`
)
var
soraVideoHTMLRe
=
regexp
.
MustCompile
(
`(?i)<video[^>]+src=['"]([^'"]+)['"]`
)
var
soraImageSizeMap
=
map
[
string
]
string
{
"gpt-image"
:
"360"
,
"gpt-image-landscape"
:
"540"
,
"gpt-image-portrait"
:
"540"
,
}
type
soraStreamingResult
struct
{
content
string
mediaType
string
mediaURLs
[]
string
imageCount
int
imageSize
string
firstTokenMs
*
int
}
// SoraGatewayService handles forwarding requests to sora2api.
type
SoraGatewayService
struct
{
sora2api
*
Sora2APIService
httpUpstream
HTTPUpstream
rateLimitService
*
RateLimitService
cfg
*
config
.
Config
}
func
NewSoraGatewayService
(
sora2api
*
Sora2APIService
,
httpUpstream
HTTPUpstream
,
rateLimitService
*
RateLimitService
,
cfg
*
config
.
Config
,
)
*
SoraGatewayService
{
return
&
SoraGatewayService
{
sora2api
:
sora2api
,
httpUpstream
:
httpUpstream
,
rateLimitService
:
rateLimitService
,
cfg
:
cfg
,
}
}
func
(
s
*
SoraGatewayService
)
Forward
(
ctx
context
.
Context
,
c
*
gin
.
Context
,
account
*
Account
,
body
[]
byte
,
clientStream
bool
)
(
*
ForwardResult
,
error
)
{
startTime
:=
time
.
Now
()
if
s
.
sora2api
==
nil
||
!
s
.
sora2api
.
Enabled
()
{
if
c
!=
nil
{
c
.
JSON
(
http
.
StatusServiceUnavailable
,
gin
.
H
{
"error"
:
gin
.
H
{
"type"
:
"api_error"
,
"message"
:
"sora2api 未配置"
,
},
})
}
return
nil
,
errors
.
New
(
"sora2api not configured"
)
}
var
reqBody
map
[
string
]
any
if
err
:=
json
.
Unmarshal
(
body
,
&
reqBody
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"parse request: %w"
,
err
)
}
reqModel
,
_
:=
reqBody
[
"model"
]
.
(
string
)
reqStream
,
_
:=
reqBody
[
"stream"
]
.
(
bool
)
mappedModel
:=
account
.
GetMappedModel
(
reqModel
)
if
mappedModel
!=
reqModel
&&
mappedModel
!=
""
{
reqBody
[
"model"
]
=
mappedModel
if
updated
,
err
:=
json
.
Marshal
(
reqBody
);
err
==
nil
{
body
=
updated
}
}
reqCtx
,
cancel
:=
s
.
withSoraTimeout
(
ctx
,
reqStream
)
if
cancel
!=
nil
{
defer
cancel
()
}
upstreamReq
,
err
:=
s
.
sora2api
.
NewAPIRequest
(
reqCtx
,
http
.
MethodPost
,
"/v1/chat/completions"
,
body
)
if
err
!=
nil
{
return
nil
,
err
}
if
c
!=
nil
{
if
ua
:=
strings
.
TrimSpace
(
c
.
GetHeader
(
"User-Agent"
));
ua
!=
""
{
upstreamReq
.
Header
.
Set
(
"User-Agent"
,
ua
)
}
}
if
reqStream
{
upstreamReq
.
Header
.
Set
(
"Accept"
,
"text/event-stream"
)
}
if
c
!=
nil
{
c
.
Set
(
OpsUpstreamRequestBodyKey
,
string
(
body
))
}
proxyURL
:=
""
if
account
!=
nil
&&
account
.
ProxyID
!=
nil
&&
account
.
Proxy
!=
nil
{
proxyURL
=
account
.
Proxy
.
URL
()
}
var
resp
*
http
.
Response
if
s
.
httpUpstream
!=
nil
{
resp
,
err
=
s
.
httpUpstream
.
Do
(
upstreamReq
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
)
}
else
{
resp
,
err
=
http
.
DefaultClient
.
Do
(
upstreamReq
)
}
if
err
!=
nil
{
s
.
setUpstreamRequestError
(
c
,
account
,
err
)
return
nil
,
fmt
.
Errorf
(
"upstream request failed: %w"
,
err
)
}
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
if
resp
.
StatusCode
>=
400
{
if
s
.
shouldFailoverUpstreamError
(
resp
.
StatusCode
)
{
respBody
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
2
<<
20
))
_
=
resp
.
Body
.
Close
()
resp
.
Body
=
io
.
NopCloser
(
bytes
.
NewReader
(
respBody
))
upstreamMsg
:=
strings
.
TrimSpace
(
extractUpstreamErrorMessage
(
respBody
))
upstreamMsg
=
sanitizeUpstreamErrorMessage
(
upstreamMsg
)
appendOpsUpstreamError
(
c
,
OpsUpstreamErrorEvent
{
Platform
:
account
.
Platform
,
AccountID
:
account
.
ID
,
AccountName
:
account
.
Name
,
UpstreamStatusCode
:
resp
.
StatusCode
,
UpstreamRequestID
:
resp
.
Header
.
Get
(
"x-request-id"
),
Kind
:
"failover"
,
Message
:
upstreamMsg
,
})
s
.
handleFailoverSideEffects
(
ctx
,
resp
,
account
)
return
nil
,
&
UpstreamFailoverError
{
StatusCode
:
resp
.
StatusCode
}
}
return
s
.
handleErrorResponse
(
ctx
,
resp
,
c
,
account
,
reqModel
)
}
streamResult
,
err
:=
s
.
handleStreamingResponse
(
ctx
,
resp
,
c
,
account
,
startTime
,
reqModel
,
clientStream
)
if
err
!=
nil
{
return
nil
,
err
}
result
:=
&
ForwardResult
{
RequestID
:
resp
.
Header
.
Get
(
"x-request-id"
),
Model
:
reqModel
,
Stream
:
clientStream
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
streamResult
.
firstTokenMs
,
Usage
:
ClaudeUsage
{},
MediaType
:
streamResult
.
mediaType
,
MediaURL
:
firstMediaURL
(
streamResult
.
mediaURLs
),
ImageCount
:
streamResult
.
imageCount
,
ImageSize
:
streamResult
.
imageSize
,
}
return
result
,
nil
}
func
(
s
*
SoraGatewayService
)
withSoraTimeout
(
ctx
context
.
Context
,
stream
bool
)
(
context
.
Context
,
context
.
CancelFunc
)
{
if
s
==
nil
||
s
.
cfg
==
nil
{
return
ctx
,
nil
}
timeoutSeconds
:=
s
.
cfg
.
Gateway
.
SoraRequestTimeoutSeconds
if
stream
{
timeoutSeconds
=
s
.
cfg
.
Gateway
.
SoraStreamTimeoutSeconds
}
if
timeoutSeconds
<=
0
{
return
ctx
,
nil
}
return
context
.
WithTimeout
(
ctx
,
time
.
Duration
(
timeoutSeconds
)
*
time
.
Second
)
}
func
(
s
*
SoraGatewayService
)
setUpstreamRequestError
(
c
*
gin
.
Context
,
account
*
Account
,
err
error
)
{
safeErr
:=
sanitizeUpstreamErrorMessage
(
err
.
Error
())
setOpsUpstreamError
(
c
,
0
,
safeErr
,
""
)
appendOpsUpstreamError
(
c
,
OpsUpstreamErrorEvent
{
Platform
:
account
.
Platform
,
AccountID
:
account
.
ID
,
AccountName
:
account
.
Name
,
UpstreamStatusCode
:
0
,
Kind
:
"request_error"
,
Message
:
safeErr
,
})
if
c
!=
nil
{
c
.
JSON
(
http
.
StatusBadGateway
,
gin
.
H
{
"error"
:
gin
.
H
{
"type"
:
"upstream_error"
,
"message"
:
"Upstream request failed"
,
},
})
}
}
func
(
s
*
SoraGatewayService
)
shouldFailoverUpstreamError
(
statusCode
int
)
bool
{
switch
statusCode
{
case
401
,
402
,
403
,
429
,
529
:
return
true
default
:
return
statusCode
>=
500
}
}
func
(
s
*
SoraGatewayService
)
handleFailoverSideEffects
(
ctx
context
.
Context
,
resp
*
http
.
Response
,
account
*
Account
)
{
if
s
.
rateLimitService
==
nil
||
account
==
nil
||
resp
==
nil
{
return
}
body
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
2
<<
20
))
s
.
rateLimitService
.
HandleUpstreamError
(
ctx
,
account
,
resp
.
StatusCode
,
resp
.
Header
,
body
)
}
func
(
s
*
SoraGatewayService
)
handleErrorResponse
(
ctx
context
.
Context
,
resp
*
http
.
Response
,
c
*
gin
.
Context
,
account
*
Account
,
reqModel
string
)
(
*
ForwardResult
,
error
)
{
respBody
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
2
<<
20
))
_
=
resp
.
Body
.
Close
()
resp
.
Body
=
io
.
NopCloser
(
bytes
.
NewReader
(
respBody
))
upstreamMsg
:=
strings
.
TrimSpace
(
extractUpstreamErrorMessage
(
respBody
))
upstreamMsg
=
sanitizeUpstreamErrorMessage
(
upstreamMsg
)
if
msg
:=
soraProErrorMessage
(
reqModel
,
upstreamMsg
);
msg
!=
""
{
upstreamMsg
=
msg
}
upstreamDetail
:=
""
if
s
.
cfg
!=
nil
&&
s
.
cfg
.
Gateway
.
LogUpstreamErrorBody
{
maxBytes
:=
s
.
cfg
.
Gateway
.
LogUpstreamErrorBodyMaxBytes
if
maxBytes
<=
0
{
maxBytes
=
2048
}
upstreamDetail
=
truncateString
(
string
(
respBody
),
maxBytes
)
}
setOpsUpstreamError
(
c
,
resp
.
StatusCode
,
upstreamMsg
,
upstreamDetail
)
appendOpsUpstreamError
(
c
,
OpsUpstreamErrorEvent
{
Platform
:
account
.
Platform
,
AccountID
:
account
.
ID
,
AccountName
:
account
.
Name
,
UpstreamStatusCode
:
resp
.
StatusCode
,
UpstreamRequestID
:
resp
.
Header
.
Get
(
"x-request-id"
),
Kind
:
"http_error"
,
Message
:
upstreamMsg
,
Detail
:
upstreamDetail
,
})
if
c
!=
nil
{
responsePayload
:=
s
.
buildErrorPayload
(
respBody
,
upstreamMsg
)
c
.
JSON
(
resp
.
StatusCode
,
responsePayload
)
}
if
upstreamMsg
==
""
{
return
nil
,
fmt
.
Errorf
(
"upstream error: %d"
,
resp
.
StatusCode
)
}
return
nil
,
fmt
.
Errorf
(
"upstream error: %d message=%s"
,
resp
.
StatusCode
,
upstreamMsg
)
}
func
(
s
*
SoraGatewayService
)
buildErrorPayload
(
respBody
[]
byte
,
overrideMessage
string
)
map
[
string
]
any
{
if
len
(
respBody
)
>
0
{
var
payload
map
[
string
]
any
if
err
:=
json
.
Unmarshal
(
respBody
,
&
payload
);
err
==
nil
{
if
errObj
,
ok
:=
payload
[
"error"
]
.
(
map
[
string
]
any
);
ok
{
if
overrideMessage
!=
""
{
errObj
[
"message"
]
=
overrideMessage
}
payload
[
"error"
]
=
errObj
return
payload
}
}
}
return
map
[
string
]
any
{
"error"
:
map
[
string
]
any
{
"type"
:
"upstream_error"
,
"message"
:
overrideMessage
,
},
}
}
func
(
s
*
SoraGatewayService
)
handleStreamingResponse
(
ctx
context
.
Context
,
resp
*
http
.
Response
,
c
*
gin
.
Context
,
account
*
Account
,
startTime
time
.
Time
,
originalModel
string
,
clientStream
bool
)
(
*
soraStreamingResult
,
error
)
{
if
resp
==
nil
{
return
nil
,
errors
.
New
(
"empty response"
)
}
if
clientStream
{
c
.
Header
(
"Content-Type"
,
"text/event-stream"
)
c
.
Header
(
"Cache-Control"
,
"no-cache"
)
c
.
Header
(
"Connection"
,
"keep-alive"
)
c
.
Header
(
"X-Accel-Buffering"
,
"no"
)
if
v
:=
resp
.
Header
.
Get
(
"x-request-id"
);
v
!=
""
{
c
.
Header
(
"x-request-id"
,
v
)
}
}
w
:=
c
.
Writer
flusher
,
_
:=
w
.
(
http
.
Flusher
)
contentBuilder
:=
strings
.
Builder
{}
var
firstTokenMs
*
int
var
upstreamError
error
scanner
:=
bufio
.
NewScanner
(
resp
.
Body
)
maxLineSize
:=
defaultMaxLineSize
if
s
.
cfg
!=
nil
&&
s
.
cfg
.
Gateway
.
MaxLineSize
>
0
{
maxLineSize
=
s
.
cfg
.
Gateway
.
MaxLineSize
}
scanner
.
Buffer
(
make
([]
byte
,
64
*
1024
),
maxLineSize
)
sendLine
:=
func
(
line
string
)
error
{
if
!
clientStream
{
return
nil
}
if
_
,
err
:=
fmt
.
Fprintf
(
w
,
"%s
\n
"
,
line
);
err
!=
nil
{
return
err
}
if
flusher
!=
nil
{
flusher
.
Flush
()
}
return
nil
}
for
scanner
.
Scan
()
{
line
:=
scanner
.
Text
()
if
soraSSEDataRe
.
MatchString
(
line
)
{
data
:=
soraSSEDataRe
.
ReplaceAllString
(
line
,
""
)
if
data
==
"[DONE]"
{
if
err
:=
sendLine
(
"data: [DONE]"
);
err
!=
nil
{
return
nil
,
err
}
break
}
updatedLine
,
contentDelta
,
errEvent
:=
s
.
processSoraSSEData
(
data
,
originalModel
)
if
errEvent
!=
nil
&&
upstreamError
==
nil
{
upstreamError
=
errEvent
}
if
contentDelta
!=
""
{
if
firstTokenMs
==
nil
{
ms
:=
int
(
time
.
Since
(
startTime
)
.
Milliseconds
())
firstTokenMs
=
&
ms
}
contentBuilder
.
WriteString
(
contentDelta
)
}
if
err
:=
sendLine
(
updatedLine
);
err
!=
nil
{
return
nil
,
err
}
continue
}
if
err
:=
sendLine
(
line
);
err
!=
nil
{
return
nil
,
err
}
}
if
err
:=
scanner
.
Err
();
err
!=
nil
{
if
errors
.
Is
(
err
,
bufio
.
ErrTooLong
)
{
if
clientStream
{
_
,
_
=
fmt
.
Fprintf
(
w
,
"event: error
\n
data: {
\"
error
\"
:
\"
response_too_large
\"
}
\n\n
"
)
if
flusher
!=
nil
{
flusher
.
Flush
()
}
}
return
nil
,
err
}
if
ctx
.
Err
()
==
context
.
DeadlineExceeded
&&
s
.
rateLimitService
!=
nil
&&
account
!=
nil
{
s
.
rateLimitService
.
HandleStreamTimeout
(
ctx
,
account
,
originalModel
)
}
if
clientStream
{
_
,
_
=
fmt
.
Fprintf
(
w
,
"event: error
\n
data: {
\"
error
\"
:
\"
stream_read_error
\"
}
\n\n
"
)
if
flusher
!=
nil
{
flusher
.
Flush
()
}
}
return
nil
,
err
}
content
:=
contentBuilder
.
String
()
mediaType
,
mediaURLs
:=
s
.
extractSoraMedia
(
content
)
if
mediaType
==
""
&&
isSoraPromptEnhanceModel
(
originalModel
)
{
mediaType
=
"prompt"
}
imageSize
:=
""
imageCount
:=
0
if
mediaType
==
"image"
{
imageSize
=
soraImageSizeFromModel
(
originalModel
)
imageCount
=
len
(
mediaURLs
)
}
if
upstreamError
!=
nil
&&
!
clientStream
{
if
c
!=
nil
{
c
.
JSON
(
http
.
StatusBadGateway
,
map
[
string
]
any
{
"error"
:
map
[
string
]
any
{
"type"
:
"upstream_error"
,
"message"
:
upstreamError
.
Error
(),
},
})
}
return
nil
,
upstreamError
}
if
!
clientStream
{
response
:=
buildSoraNonStreamResponse
(
content
,
originalModel
)
if
len
(
mediaURLs
)
>
0
{
response
[
"media_url"
]
=
mediaURLs
[
0
]
if
len
(
mediaURLs
)
>
1
{
response
[
"media_urls"
]
=
mediaURLs
}
}
c
.
JSON
(
http
.
StatusOK
,
response
)
}
return
&
soraStreamingResult
{
content
:
content
,
mediaType
:
mediaType
,
mediaURLs
:
mediaURLs
,
imageCount
:
imageCount
,
imageSize
:
imageSize
,
firstTokenMs
:
firstTokenMs
,
},
nil
}
func
(
s
*
SoraGatewayService
)
processSoraSSEData
(
data
string
,
originalModel
string
)
(
string
,
string
,
error
)
{
if
strings
.
TrimSpace
(
data
)
==
""
{
return
"data: "
,
""
,
nil
}
var
payload
map
[
string
]
any
if
err
:=
json
.
Unmarshal
([]
byte
(
data
),
&
payload
);
err
!=
nil
{
return
"data: "
+
data
,
""
,
nil
}
if
errObj
,
ok
:=
payload
[
"error"
]
.
(
map
[
string
]
any
);
ok
{
if
msg
,
ok
:=
errObj
[
"message"
]
.
(
string
);
ok
&&
strings
.
TrimSpace
(
msg
)
!=
""
{
return
"data: "
+
data
,
""
,
errors
.
New
(
msg
)
}
}
if
model
,
ok
:=
payload
[
"model"
]
.
(
string
);
ok
&&
model
!=
""
&&
originalModel
!=
""
{
payload
[
"model"
]
=
originalModel
}
contentDelta
,
updated
:=
extractSoraContent
(
payload
)
if
updated
{
rewritten
:=
s
.
rewriteSoraContent
(
contentDelta
)
if
rewritten
!=
contentDelta
{
applySoraContent
(
payload
,
rewritten
)
contentDelta
=
rewritten
}
}
updatedData
,
err
:=
json
.
Marshal
(
payload
)
if
err
!=
nil
{
return
"data: "
+
data
,
contentDelta
,
nil
}
return
"data: "
+
string
(
updatedData
),
contentDelta
,
nil
}
func
extractSoraContent
(
payload
map
[
string
]
any
)
(
string
,
bool
)
{
choices
,
ok
:=
payload
[
"choices"
]
.
([]
any
)
if
!
ok
||
len
(
choices
)
==
0
{
return
""
,
false
}
choice
,
ok
:=
choices
[
0
]
.
(
map
[
string
]
any
)
if
!
ok
{
return
""
,
false
}
if
delta
,
ok
:=
choice
[
"delta"
]
.
(
map
[
string
]
any
);
ok
{
if
content
,
ok
:=
delta
[
"content"
]
.
(
string
);
ok
{
return
content
,
true
}
}
if
message
,
ok
:=
choice
[
"message"
]
.
(
map
[
string
]
any
);
ok
{
if
content
,
ok
:=
message
[
"content"
]
.
(
string
);
ok
{
return
content
,
true
}
}
return
""
,
false
}
func
applySoraContent
(
payload
map
[
string
]
any
,
content
string
)
{
choices
,
ok
:=
payload
[
"choices"
]
.
([]
any
)
if
!
ok
||
len
(
choices
)
==
0
{
return
}
choice
,
ok
:=
choices
[
0
]
.
(
map
[
string
]
any
)
if
!
ok
{
return
}
if
delta
,
ok
:=
choice
[
"delta"
]
.
(
map
[
string
]
any
);
ok
{
delta
[
"content"
]
=
content
choice
[
"delta"
]
=
delta
return
}
if
message
,
ok
:=
choice
[
"message"
]
.
(
map
[
string
]
any
);
ok
{
message
[
"content"
]
=
content
choice
[
"message"
]
=
message
}
}
func
(
s
*
SoraGatewayService
)
rewriteSoraContent
(
content
string
)
string
{
if
content
==
""
{
return
content
}
content
=
soraImageMarkdownRe
.
ReplaceAllStringFunc
(
content
,
func
(
match
string
)
string
{
sub
:=
soraImageMarkdownRe
.
FindStringSubmatch
(
match
)
if
len
(
sub
)
<
2
{
return
match
}
rewritten
:=
s
.
rewriteSoraURL
(
sub
[
1
])
if
rewritten
==
sub
[
1
]
{
return
match
}
return
strings
.
Replace
(
match
,
sub
[
1
],
rewritten
,
1
)
})
content
=
soraVideoHTMLRe
.
ReplaceAllStringFunc
(
content
,
func
(
match
string
)
string
{
sub
:=
soraVideoHTMLRe
.
FindStringSubmatch
(
match
)
if
len
(
sub
)
<
2
{
return
match
}
rewritten
:=
s
.
rewriteSoraURL
(
sub
[
1
])
if
rewritten
==
sub
[
1
]
{
return
match
}
return
strings
.
Replace
(
match
,
sub
[
1
],
rewritten
,
1
)
})
return
content
}
func
(
s
*
SoraGatewayService
)
rewriteSoraURL
(
raw
string
)
string
{
raw
=
strings
.
TrimSpace
(
raw
)
if
raw
==
""
{
return
raw
}
parsed
,
err
:=
url
.
Parse
(
raw
)
if
err
!=
nil
{
return
raw
}
path
:=
parsed
.
Path
if
!
strings
.
HasPrefix
(
path
,
"/tmp/"
)
&&
!
strings
.
HasPrefix
(
path
,
"/static/"
)
{
return
raw
}
return
s
.
buildSoraMediaURL
(
path
,
parsed
.
RawQuery
)
}
func
(
s
*
SoraGatewayService
)
extractSoraMedia
(
content
string
)
(
string
,
[]
string
)
{
if
content
==
""
{
return
""
,
nil
}
if
match
:=
soraVideoHTMLRe
.
FindStringSubmatch
(
content
);
len
(
match
)
>
1
{
return
"video"
,
[]
string
{
match
[
1
]}
}
imageMatches
:=
soraImageMarkdownRe
.
FindAllStringSubmatch
(
content
,
-
1
)
if
len
(
imageMatches
)
==
0
{
return
""
,
nil
}
urls
:=
make
([]
string
,
0
,
len
(
imageMatches
))
for
_
,
match
:=
range
imageMatches
{
if
len
(
match
)
>
1
{
urls
=
append
(
urls
,
match
[
1
])
}
}
return
"image"
,
urls
}
func
buildSoraNonStreamResponse
(
content
,
model
string
)
map
[
string
]
any
{
return
map
[
string
]
any
{
"id"
:
fmt
.
Sprintf
(
"chatcmpl-%d"
,
time
.
Now
()
.
UnixNano
()),
"object"
:
"chat.completion"
,
"created"
:
time
.
Now
()
.
Unix
(),
"model"
:
model
,
"choices"
:
[]
any
{
map
[
string
]
any
{
"index"
:
0
,
"message"
:
map
[
string
]
any
{
"role"
:
"assistant"
,
"content"
:
content
,
},
"finish_reason"
:
"stop"
,
},
},
}
}
func
soraImageSizeFromModel
(
model
string
)
string
{
modelLower
:=
strings
.
ToLower
(
model
)
if
size
,
ok
:=
soraImageSizeMap
[
modelLower
];
ok
{
return
size
}
if
strings
.
Contains
(
modelLower
,
"landscape"
)
||
strings
.
Contains
(
modelLower
,
"portrait"
)
{
return
"540"
}
return
"360"
}
func
isSoraPromptEnhanceModel
(
model
string
)
bool
{
return
strings
.
HasPrefix
(
strings
.
ToLower
(
strings
.
TrimSpace
(
model
)),
"prompt-enhance"
)
}
func
soraProErrorMessage
(
model
,
upstreamMsg
string
)
string
{
modelLower
:=
strings
.
ToLower
(
model
)
if
strings
.
Contains
(
modelLower
,
"sora2pro-hd"
)
{
return
"当前账号无法使用 Sora Pro-HD 模型,请更换模型或账号"
}
if
strings
.
Contains
(
modelLower
,
"sora2pro"
)
{
return
"当前账号无法使用 Sora Pro 模型,请更换模型或账号"
}
return
""
}
func
firstMediaURL
(
urls
[]
string
)
string
{
if
len
(
urls
)
==
0
{
return
""
}
return
urls
[
0
]
}
func
(
s
*
SoraGatewayService
)
buildSoraMediaURL
(
path
string
,
rawQuery
string
)
string
{
if
path
==
""
{
return
path
}
prefix
:=
"/sora/media"
values
:=
url
.
Values
{}
if
rawQuery
!=
""
{
if
parsed
,
err
:=
url
.
ParseQuery
(
rawQuery
);
err
==
nil
{
values
=
parsed
}
}
signKey
:=
""
ttlSeconds
:=
0
if
s
!=
nil
&&
s
.
cfg
!=
nil
{
signKey
=
strings
.
TrimSpace
(
s
.
cfg
.
Gateway
.
SoraMediaSigningKey
)
ttlSeconds
=
s
.
cfg
.
Gateway
.
SoraMediaSignedURLTTLSeconds
}
values
.
Del
(
"sig"
)
values
.
Del
(
"expires"
)
signingQuery
:=
values
.
Encode
()
if
signKey
!=
""
&&
ttlSeconds
>
0
{
expires
:=
time
.
Now
()
.
Add
(
time
.
Duration
(
ttlSeconds
)
*
time
.
Second
)
.
Unix
()
signature
:=
SignSoraMediaURL
(
path
,
signingQuery
,
expires
,
signKey
)
if
signature
!=
""
{
values
.
Set
(
"expires"
,
strconv
.
FormatInt
(
expires
,
10
))
values
.
Set
(
"sig"
,
signature
)
prefix
=
"/sora/media-signed"
}
}
encoded
:=
values
.
Encode
()
if
encoded
==
""
{
return
prefix
+
path
}
return
prefix
+
path
+
"?"
+
encoded
}
backend/internal/service/sora_media_sign.go
0 → 100644
View file @
618a614c
package
service
import
(
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
)
// SignSoraMediaURL 生成 Sora 媒体临时签名
func
SignSoraMediaURL
(
path
string
,
query
string
,
expires
int64
,
key
string
)
string
{
key
=
strings
.
TrimSpace
(
key
)
if
key
==
""
{
return
""
}
mac
:=
hmac
.
New
(
sha256
.
New
,
[]
byte
(
key
))
mac
.
Write
([]
byte
(
buildSoraMediaSignPayload
(
path
,
query
)))
mac
.
Write
([]
byte
(
"|"
))
mac
.
Write
([]
byte
(
strconv
.
FormatInt
(
expires
,
10
)))
return
hex
.
EncodeToString
(
mac
.
Sum
(
nil
))
}
// VerifySoraMediaURL 校验 Sora 媒体签名
func
VerifySoraMediaURL
(
path
string
,
query
string
,
expires
int64
,
signature
string
,
key
string
)
bool
{
signature
=
strings
.
TrimSpace
(
signature
)
if
signature
==
""
{
return
false
}
expected
:=
SignSoraMediaURL
(
path
,
query
,
expires
,
key
)
if
expected
==
""
{
return
false
}
return
hmac
.
Equal
([]
byte
(
signature
),
[]
byte
(
expected
))
}
func
buildSoraMediaSignPayload
(
path
string
,
query
string
)
string
{
if
strings
.
TrimSpace
(
query
)
==
""
{
return
path
}
return
path
+
"?"
+
query
}
backend/internal/service/sora_media_sign_test.go
0 → 100644
View file @
618a614c
package
service
import
"testing"
func
TestSoraMediaSignVerify
(
t
*
testing
.
T
)
{
key
:=
"test-key"
path
:=
"/tmp/abc.png"
query
:=
"a=1&b=2"
expires
:=
int64
(
1700000000
)
signature
:=
SignSoraMediaURL
(
path
,
query
,
expires
,
key
)
if
signature
==
""
{
t
.
Fatal
(
"签名为空"
)
}
if
!
VerifySoraMediaURL
(
path
,
query
,
expires
,
signature
,
key
)
{
t
.
Fatal
(
"签名校验失败"
)
}
if
VerifySoraMediaURL
(
path
,
"a=1"
,
expires
,
signature
,
key
)
{
t
.
Fatal
(
"签名参数不同仍然通过"
)
}
if
VerifySoraMediaURL
(
path
,
query
,
expires
+
1
,
signature
,
key
)
{
t
.
Fatal
(
"签名过期校验未失败"
)
}
}
func
TestSoraMediaSignWithEmptyKey
(
t
*
testing
.
T
)
{
signature
:=
SignSoraMediaURL
(
"/tmp/a.png"
,
"a=1"
,
1
,
""
)
if
signature
!=
""
{
t
.
Fatalf
(
"空密钥不应生成签名"
)
}
if
VerifySoraMediaURL
(
"/tmp/a.png"
,
"a=1"
,
1
,
"sig"
,
""
)
{
t
.
Fatalf
(
"空密钥不应通过校验"
)
}
}
backend/internal/service/token_cache_invalidator.go
View file @
618a614c
...
...
@@ -42,7 +42,7 @@ func (c *CompositeTokenCacheInvalidator) InvalidateToken(ctx context.Context, ac
// Antigravity 同样可能有两种缓存键
keysToDelete
=
append
(
keysToDelete
,
AntigravityTokenCacheKey
(
account
))
keysToDelete
=
append
(
keysToDelete
,
"ag:"
+
accountIDKey
)
case
PlatformOpenAI
:
case
PlatformOpenAI
,
PlatformSora
:
keysToDelete
=
append
(
keysToDelete
,
OpenAITokenCacheKey
(
account
))
case
PlatformAnthropic
:
keysToDelete
=
append
(
keysToDelete
,
ClaudeTokenCacheKey
(
account
))
...
...
backend/internal/service/token_refresh_service.go
View file @
618a614c
...
...
@@ -19,6 +19,7 @@ type TokenRefreshService struct {
refreshers
[]
TokenRefresher
cfg
*
config
.
TokenRefreshConfig
cacheInvalidator
TokenCacheInvalidator
soraSyncService
*
Sora2APISyncService
stopCh
chan
struct
{}
wg
sync
.
WaitGroup
...
...
@@ -65,6 +66,17 @@ func (s *TokenRefreshService) SetSoraAccountRepo(repo SoraAccountRepository) {
}
}
// SetSoraSyncService 设置 Sora2API 同步服务
// 需要在 Start() 之前调用
func
(
s
*
TokenRefreshService
)
SetSoraSyncService
(
svc
*
Sora2APISyncService
)
{
s
.
soraSyncService
=
svc
for
_
,
refresher
:=
range
s
.
refreshers
{
if
openaiRefresher
,
ok
:=
refresher
.
(
*
OpenAITokenRefresher
);
ok
{
openaiRefresher
.
SetSoraSyncService
(
svc
)
}
}
}
// Start 启动后台刷新服务
func
(
s
*
TokenRefreshService
)
Start
()
{
if
!
s
.
cfg
.
Enabled
{
...
...
backend/internal/service/token_refresher.go
View file @
618a614c
...
...
@@ -86,6 +86,7 @@ type OpenAITokenRefresher struct {
openaiOAuthService
*
OpenAIOAuthService
accountRepo
AccountRepository
soraAccountRepo
SoraAccountRepository
// Sora 扩展表仓储,用于双表同步
soraSyncService
*
Sora2APISyncService
// Sora2API 同步服务
}
// NewOpenAITokenRefresher 创建 OpenAI token刷新器
...
...
@@ -103,17 +104,22 @@ func (r *OpenAITokenRefresher) SetSoraAccountRepo(repo SoraAccountRepository) {
r
.
soraAccountRepo
=
repo
}
// SetSoraSyncService 设置 Sora2API 同步服务
func
(
r
*
OpenAITokenRefresher
)
SetSoraSyncService
(
svc
*
Sora2APISyncService
)
{
r
.
soraSyncService
=
svc
}
// CanRefresh 检查是否能处理此账号
// 只处理 openai 平台的 oauth 类型账号
func
(
r
*
OpenAITokenRefresher
)
CanRefresh
(
account
*
Account
)
bool
{
return
account
.
Platform
==
PlatformOpenAI
&&
return
(
account
.
Platform
==
PlatformOpenAI
||
account
.
Platform
==
PlatformSora
)
&&
account
.
Type
==
AccountTypeOAuth
}
// NeedsRefresh 检查token是否需要刷新
// 基于 expires_at 字段判断是否在刷新窗口内
func
(
r
*
OpenAITokenRefresher
)
NeedsRefresh
(
account
*
Account
,
refreshWindow
time
.
Duration
)
bool
{
expiresAt
:=
account
.
Get
OpenAITokenE
xpires
At
(
)
expiresAt
:=
account
.
Get
CredentialAsTime
(
"e
xpires
_at"
)
if
expiresAt
==
nil
{
return
false
}
...
...
@@ -145,6 +151,17 @@ func (r *OpenAITokenRefresher) Refresh(ctx context.Context, account *Account) (m
go
r
.
syncLinkedSoraAccounts
(
context
.
Background
(),
account
.
ID
,
newCredentials
)
}
// 如果是 Sora 平台账号,同步到 sora2api(不阻塞主流程)
if
account
.
Platform
==
PlatformSora
&&
r
.
soraSyncService
!=
nil
{
syncAccount
:=
*
account
syncAccount
.
Credentials
=
newCredentials
go
func
()
{
if
err
:=
r
.
soraSyncService
.
SyncAccount
(
context
.
Background
(),
&
syncAccount
);
err
!=
nil
{
log
.
Printf
(
"[TokenSync] 同步 Sora2API 失败: account_id=%d err=%v"
,
syncAccount
.
ID
,
err
)
}
}()
}
return
newCredentials
,
nil
}
...
...
@@ -201,6 +218,13 @@ func (r *OpenAITokenRefresher) syncLinkedSoraAccounts(ctx context.Context, opena
}
}
// 2.3 同步到 sora2api(如果配置)
if
r
.
soraSyncService
!=
nil
{
if
err
:=
r
.
soraSyncService
.
SyncAccount
(
ctx
,
&
soraAccount
);
err
!=
nil
{
log
.
Printf
(
"[TokenSync] 同步 sora2api 失败: account_id=%d err=%v"
,
soraAccount
.
ID
,
err
)
}
}
log
.
Printf
(
"[TokenSync] 成功同步 Sora 账号 token: sora_account_id=%d openai_account_id=%d dual_table=%v"
,
soraAccount
.
ID
,
openaiAccountID
,
r
.
soraAccountRepo
!=
nil
)
}
...
...
backend/internal/service/usage_log.go
View file @
618a614c
...
...
@@ -46,6 +46,7 @@ type UsageLog struct {
// 图片生成字段
ImageCount
int
ImageSize
*
string
MediaType
*
string
CreatedAt
time
.
Time
...
...
backend/internal/service/wire.go
View file @
618a614c
...
...
@@ -40,6 +40,7 @@ func ProvideEmailQueueService(emailService *EmailService) *EmailQueueService {
func
ProvideTokenRefreshService
(
accountRepo
AccountRepository
,
soraAccountRepo
SoraAccountRepository
,
// Sora 扩展表仓储,用于双表同步
soraSyncService
*
Sora2APISyncService
,
oauthService
*
OAuthService
,
openaiOAuthService
*
OpenAIOAuthService
,
geminiOAuthService
*
GeminiOAuthService
,
...
...
@@ -50,6 +51,9 @@ func ProvideTokenRefreshService(
svc
:=
NewTokenRefreshService
(
accountRepo
,
oauthService
,
openaiOAuthService
,
geminiOAuthService
,
antigravityOAuthService
,
cacheInvalidator
,
cfg
)
// 注入 Sora 账号扩展表仓储,用于 OpenAI Token 刷新时同步 sora_accounts 表
svc
.
SetSoraAccountRepo
(
soraAccountRepo
)
if
soraSyncService
!=
nil
{
svc
.
SetSoraSyncService
(
soraSyncService
)
}
svc
.
Start
()
return
svc
}
...
...
@@ -224,6 +228,7 @@ var ProviderSet = wire.NewSet(
NewBillingCacheService
,
NewAdminService
,
NewGatewayService
,
NewSoraGatewayService
,
NewOpenAIGatewayService
,
NewOAuthService
,
NewOpenAIOAuthService
,
...
...
@@ -237,6 +242,8 @@ var ProviderSet = wire.NewSet(
NewAntigravityTokenProvider
,
NewOpenAITokenProvider
,
NewClaudeTokenProvider
,
NewSora2APIService
,
NewSora2APISyncService
,
NewAntigravityGatewayService
,
ProvideRateLimitService
,
NewAccountUsageService
,
...
...
backend/migrations/047_add_sora_pricing_and_media_type.sql
0 → 100644
View file @
618a614c
-- Migration: 047_add_sora_pricing_and_media_type
-- 新增 Sora 按次计费字段与 usage_logs.media_type
ALTER
TABLE
groups
ADD
COLUMN
IF
NOT
EXISTS
sora_image_price_360
decimal
(
20
,
8
),
ADD
COLUMN
IF
NOT
EXISTS
sora_image_price_540
decimal
(
20
,
8
),
ADD
COLUMN
IF
NOT
EXISTS
sora_video_price_per_request
decimal
(
20
,
8
),
ADD
COLUMN
IF
NOT
EXISTS
sora_video_price_per_request_hd
decimal
(
20
,
8
);
ALTER
TABLE
usage_logs
ADD
COLUMN
IF
NOT
EXISTS
media_type
VARCHAR
(
16
);
deploy/Caddyfile
View file @
618a614c
# =============================================================================
# Sub2API Caddy Reverse Proxy Configuration (宿主机部署)
# =============================================================================
# 使用方法:
# 1. 安装 Caddy: https://caddyserver.com/docs/install
# 2. 修改下方 example.com 为你的域名
# 3. 确保域名 DNS 已指向服务器
# 4. 复制配置: sudo cp Caddyfile /etc/caddy/Caddyfile
# 5. 重载配置: sudo systemctl reload caddy
#
# Caddy 会自动申请和续期 Let's Encrypt SSL 证书
# =============================================================================
# 全局配置
{
# Let's Encrypt 邮箱通知
email admin@example.com
# 服务器配置
servers {
# 启用 HTTP/2 和 HTTP/3
protocols h1 h2 h3
# 超时配置
timeouts {
read_body 30s
read_header 10s
write 300s
idle 300s
}
}
}
# 修改为你的域名
example
.com {
# =========================================================================
api.sub2api
.com {
# =========================================================================
# 静态资源长期缓存(高优先级,放在最前面)
# 带 hash 的文件可以永久缓存,浏览器和 CDN 都会缓存
# =========================================================================
...
...
@@ -87,17 +54,13 @@ example.com {
# 连接池优化
transport http {
versions h2c h1
keepalive 120s
keepalive_idle_conns 256
read_buffer 16KB
write_buffer 16KB
compression off
}
# SSE/流式传输优化:禁用响应缓冲,立即刷新数据给客户端
flush_interval -1
# 故障转移
fail_duration 30s
max_fails 3
...
...
@@ -112,10 +75,6 @@ example.com {
gzip 6
minimum_length 256
match {
# SSE 请求通常会带 Accept: text/event-stream,需排除压缩
not header Accept text/event-stream*
# 排除已知 SSE 路径(即便 Accept 缺失)
not path /v1/messages /v1/responses /responses /antigravity/v1/messages /v1beta/models/* /antigravity/v1beta/models/*
header Content-Type text/*
header Content-Type application/json*
header Content-Type application/javascript*
...
...
@@ -199,7 +158,3 @@ example.com {
respond "{err.status_code} {err.status_text}"
}
}
# =============================================================================
# HTTP 重定向到 HTTPS (Caddy 默认自动处理,此处显式声明)
# =============================================================================
deploy/config.example.yaml
View file @
618a614c
...
...
@@ -116,6 +116,33 @@ gateway:
# Max request body size in bytes (default: 100MB)
# 请求体最大字节数(默认 100MB)
max_body_size
:
104857600
# Sora max request body size in bytes (0=use max_body_size)
# Sora 请求体最大字节数(0=使用 max_body_size)
sora_max_body_size
:
268435456
# Sora stream timeout (seconds, 0=disable)
# Sora 流式请求总超时(秒,0=禁用)
sora_stream_timeout_seconds
:
900
# Sora non-stream timeout (seconds, 0=disable)
# Sora 非流式请求超时(秒,0=禁用)
sora_request_timeout_seconds
:
180
# Sora stream enforcement mode: force/error
# Sora stream 强制策略:force/error
sora_stream_mode
:
"
force"
# Sora model filters
# Sora 模型过滤配置
sora_model_filters
:
# Hide prompt-enhance models by default
# 默认隐藏 prompt-enhance 模型
hide_prompt_enhance
:
true
# Require API key for /sora/media proxy (default: false)
# /sora/media 是否强制要求 API Key(默认 true)
sora_media_require_api_key
:
true
# Sora media temporary signing key (empty disables signed URL)
# Sora 媒体临时签名密钥(为空则禁用签名)
sora_media_signing_key
:
"
"
# Signed URL TTL seconds (<=0 disables)
# 临时签名 URL 有效期(秒,<=0 表示禁用)
sora_media_signed_url_ttl_seconds
:
900
# Connection pool isolation strategy:
# 连接池隔离策略:
# - proxy: Isolate by proxy, same proxy shares connection pool (suitable for few proxies, many accounts)
...
...
@@ -220,6 +247,31 @@ gateway:
# name: "Custom Profile 1"
# profile_2:
# name: "Custom Profile 2"
# =============================================================================
# Sora2API Configuration
# Sora2API 配置
# =============================================================================
sora2api
:
# Sora2API base URL
# Sora2API 服务地址
base_url
:
"
http://127.0.0.1:8000"
# Sora2API API Key (for /v1/chat/completions and /v1/models)
# Sora2API API Key(用于生成/模型列表)
api_key
:
"
"
# Admin username/password (for token sync)
# 管理口用户名/密码(用于 token 同步)
admin_username
:
"
admin"
admin_password
:
"
admin"
# Admin token cache ttl (seconds)
# 管理口 token 缓存时长(秒)
admin_token_ttl_seconds
:
900
# Admin request timeout (seconds)
# 管理口请求超时(秒)
admin_timeout_seconds
:
10
# Token import mode: at/offline
# Token 导入模式:at/offline
token_import_mode
:
"
at"
# cipher_suites: [4866, 4867, 4865, 49199, 49195, 49200, 49196]
# curves: [29, 23, 24]
# point_formats: [0]
...
...
frontend/src/api/admin/index.ts
View file @
618a614c
...
...
@@ -18,6 +18,7 @@ import geminiAPI from './gemini'
import
antigravityAPI
from
'
./antigravity
'
import
userAttributesAPI
from
'
./userAttributes
'
import
opsAPI
from
'
./ops
'
import
modelsAPI
from
'
./models
'
/**
* Unified admin API object for convenient access
...
...
@@ -37,7 +38,8 @@ export const adminAPI = {
gemini
:
geminiAPI
,
antigravity
:
antigravityAPI
,
userAttributes
:
userAttributesAPI
,
ops
:
opsAPI
ops
:
opsAPI
,
models
:
modelsAPI
}
export
{
...
...
@@ -55,7 +57,8 @@ export {
geminiAPI
,
antigravityAPI
,
userAttributesAPI
,
opsAPI
opsAPI
,
modelsAPI
}
export
default
adminAPI
frontend/src/api/admin/models.ts
0 → 100644
View file @
618a614c
import
{
apiClient
}
from
'
@/api/client
'
export
async
function
getPlatformModels
(
platform
:
string
):
Promise
<
string
[]
>
{
const
{
data
}
=
await
apiClient
.
get
<
string
[]
>
(
'
/admin/models
'
,
{
params
:
{
platform
}
})
return
data
}
export
const
modelsAPI
=
{
getPlatformModels
}
export
default
modelsAPI
frontend/src/components/account/ModelWhitelistSelector.vue
View file @
618a614c
...
...
@@ -45,6 +45,19 @@
:
placeholder
=
"
t('admin.accounts.searchModels')
"
@
click
.
stop
/>
<
div
v
-
if
=
"
props.platform === 'sora'
"
class
=
"
mt-2 flex items-center gap-2 text-xs
"
>
<
span
v
-
if
=
"
loadingSoraModels
"
class
=
"
text-gray-500
"
>
{{
t
(
'
admin.accounts.soraModelsLoading
'
)
}}
<
/span
>
<
button
v
-
else
-
if
=
"
soraLoadError
"
type
=
"
button
"
class
=
"
text-primary-600 hover:underline dark:text-primary-400
"
@
click
.
stop
=
"
loadSoraModels
"
>
{{
t
(
'
admin.accounts.soraModelsRetry
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
div
class
=
"
max-h-52 overflow-auto
"
>
<
button
...
...
@@ -120,12 +133,13 @@
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
computed
}
from
'
vue
'
import
{
ref
,
computed
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
ModelIcon
from
'
@/components/common/ModelIcon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
allModels
,
getModelsByPlatform
}
from
'
@/composables/useModelWhitelist
'
import
{
adminAPI
}
from
'
@/api/admin
'
const
{
t
}
=
useI18n
()
...
...
@@ -144,11 +158,24 @@ const showDropdown = ref(false)
const
searchQuery
=
ref
(
''
)
const
customModel
=
ref
(
''
)
const
isComposing
=
ref
(
false
)
const
soraModelOptions
=
ref
<
{
value
:
string
;
label
:
string
}
[]
>
([])
const
loadingSoraModels
=
ref
(
false
)
const
soraLoadError
=
ref
(
false
)
const
availableOptions
=
computed
(()
=>
{
if
(
props
.
platform
===
'
sora
'
)
{
if
(
soraModelOptions
.
value
.
length
>
0
)
{
return
soraModelOptions
.
value
}
return
getModelsByPlatform
(
'
sora
'
).
map
(
m
=>
({
value
:
m
,
label
:
m
}
))
}
return
allModels
}
)
const
filteredModels
=
computed
(()
=>
{
const
query
=
searchQuery
.
value
.
toLowerCase
().
trim
()
if
(
!
query
)
return
a
llModels
return
a
llModels
.
filter
(
if
(
!
query
)
return
a
vailableOptions
.
value
return
a
vailableOptions
.
value
.
filter
(
m
=>
m
.
value
.
toLowerCase
().
includes
(
query
)
||
m
.
label
.
toLowerCase
().
includes
(
query
)
)
}
)
...
...
@@ -186,7 +213,9 @@ const handleEnter = () => {
}
const
fillRelated
=
()
=>
{
const
models
=
getModelsByPlatform
(
props
.
platform
)
const
models
=
props
.
platform
===
'
sora
'
&&
soraModelOptions
.
value
.
length
>
0
?
soraModelOptions
.
value
.
map
(
m
=>
m
.
value
)
:
getModelsByPlatform
(
props
.
platform
)
const
newModels
=
[...
props
.
modelValue
]
for
(
const
model
of
models
)
{
if
(
!
newModels
.
includes
(
model
))
newModels
.
push
(
model
)
...
...
@@ -197,4 +226,32 @@ const fillRelated = () => {
const
clearAll
=
()
=>
{
emit
(
'
update:modelValue
'
,
[])
}
const
loadSoraModels
=
async
()
=>
{
if
(
props
.
platform
!==
'
sora
'
)
{
soraModelOptions
.
value
=
[]
return
}
if
(
loadingSoraModels
.
value
)
return
soraLoadError
.
value
=
false
loadingSoraModels
.
value
=
true
try
{
const
models
=
await
adminAPI
.
models
.
getPlatformModels
(
'
sora
'
)
soraModelOptions
.
value
=
(
models
||
[]).
map
((
m
)
=>
({
value
:
m
,
label
:
m
}
))
}
catch
(
error
)
{
console
.
warn
(
'
加载 Sora 模型列表失败
'
,
error
)
soraLoadError
.
value
=
true
appStore
.
showWarning
(
t
(
'
admin.accounts.soraModelsLoadFailed
'
))
}
finally
{
loadingSoraModels
.
value
=
false
}
}
watch
(
()
=>
props
.
platform
,
()
=>
{
loadSoraModels
()
}
,
{
immediate
:
true
}
)
<
/script
>
frontend/src/components/admin/account/AccountTableFilters.vue
View file @
618a614c
...
...
@@ -19,7 +19,7 @@ const props = defineProps(['searchQuery', 'filters']); const emit = defineEmits(
const
updatePlatform
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
emit
(
'
update:filters
'
,
{
...
props
.
filters
,
platform
:
value
})
}
const
updateType
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
emit
(
'
update:filters
'
,
{
...
props
.
filters
,
type
:
value
})
}
const
updateStatus
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
emit
(
'
update:filters
'
,
{
...
props
.
filters
,
status
:
value
})
}
const
pOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allPlatforms
'
)
},
{
value
:
'
anthropic
'
,
label
:
'
Anthropic
'
},
{
value
:
'
openai
'
,
label
:
'
OpenAI
'
},
{
value
:
'
gemini
'
,
label
:
'
Gemini
'
},
{
value
:
'
antigravity
'
,
label
:
'
Antigravity
'
}])
const
pOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allPlatforms
'
)
},
{
value
:
'
anthropic
'
,
label
:
'
Anthropic
'
},
{
value
:
'
openai
'
,
label
:
'
OpenAI
'
},
{
value
:
'
gemini
'
,
label
:
'
Gemini
'
},
{
value
:
'
antigravity
'
,
label
:
'
Antigravity
'
},
{
value
:
'
sora
'
,
label
:
'
Sora
'
}])
const
tOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allTypes
'
)
},
{
value
:
'
oauth
'
,
label
:
t
(
'
admin.accounts.oauthType
'
)
},
{
value
:
'
setup-token
'
,
label
:
t
(
'
admin.accounts.setupToken
'
)
},
{
value
:
'
apikey
'
,
label
:
t
(
'
admin.accounts.apiKey
'
)
}])
const
sOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allStatus
'
)
},
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status.active
'
)
},
{
value
:
'
inactive
'
,
label
:
t
(
'
admin.accounts.status.inactive
'
)
},
{
value
:
'
error
'
,
label
:
t
(
'
admin.accounts.status.error
'
)
}])
</
script
>
frontend/src/components/common/GroupBadge.vue
View file @
618a614c
...
...
@@ -97,6 +97,9 @@ const labelClass = computed(() => {
if
(
props
.
platform
===
'
gemini
'
)
{
return
`
${
base
}
bg-blue-200/60 text-blue-800 dark:bg-blue-800/40 dark:text-blue-300`
}
if
(
props
.
platform
===
'
sora
'
)
{
return
`
${
base
}
bg-rose-200/60 text-rose-800 dark:bg-rose-800/40 dark:text-rose-300`
}
return
`
${
base
}
bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
})
...
...
@@ -118,6 +121,11 @@ const badgeClass = computed(() => {
?
'
bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400
'
:
'
bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400
'
}
if
(
props
.
platform
===
'
sora
'
)
{
return
isSubscription
.
value
?
'
bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400
'
:
'
bg-rose-50 text-rose-700 dark:bg-rose-900/20 dark:text-rose-400
'
}
// Fallback: original colors
return
isSubscription
.
value
?
'
bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400
'
...
...
Prev
1
2
3
4
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