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
6c469b42
Commit
6c469b42
authored
Dec 22, 2025
by
shaw
Browse files
feat: 新增支持codex转发
parent
dacf3a2a
Changes
46
Show whitespace changes
Inline
Side-by-side
backend/internal/service/account_test_service.go
View file @
6c469b42
...
...
@@ -14,7 +14,9 @@ import (
"strings"
"time"
"sub2api/internal/model"
"sub2api/internal/pkg/claude"
"sub2api/internal/pkg/openai"
"sub2api/internal/service/ports"
"github.com/gin-gonic/gin"
...
...
@@ -23,6 +25,8 @@ import (
const
(
testClaudeAPIURL
=
"https://api.anthropic.com/v1/messages"
testOpenAIAPIURL
=
"https://api.openai.com/v1/responses"
chatgptCodexAPIURL
=
"https://chatgpt.com/backend-api/codex/responses"
)
// TestEvent represents a SSE event for account testing
...
...
@@ -38,15 +42,17 @@ type TestEvent struct {
type
AccountTestService
struct
{
accountRepo
ports
.
AccountRepository
oauthService
*
OAuthService
claudeUpstream
ClaudeUpstream
openaiOAuthService
*
OpenAIOAuthService
httpUpstream
ports
.
HTTPUpstream
}
// NewAccountTestService creates a new AccountTestService
func
NewAccountTestService
(
accountRepo
ports
.
AccountRepository
,
oauthService
*
OAuthService
,
claudeUpstream
Claude
Upstream
)
*
AccountTestService
{
func
NewAccountTestService
(
accountRepo
ports
.
AccountRepository
,
oauthService
*
OAuthService
,
openaiOAuthService
*
OpenAIOAuthService
,
httpUpstream
ports
.
HTTP
Upstream
)
*
AccountTestService
{
return
&
AccountTestService
{
accountRepo
:
accountRepo
,
oauthService
:
oauthService
,
claudeUpstream
:
claudeUpstream
,
openaiOAuthService
:
openaiOAuthService
,
httpUpstream
:
httpUpstream
,
}
}
...
...
@@ -114,6 +120,18 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
return
s
.
sendErrorAndEnd
(
c
,
"Account not found"
)
}
// Route to platform-specific test method
if
account
.
IsOpenAI
()
{
return
s
.
testOpenAIAccountConnection
(
c
,
account
,
modelID
)
}
return
s
.
testClaudeAccountConnection
(
c
,
account
,
modelID
)
}
// testClaudeAccountConnection tests an Anthropic Claude account's connection
func
(
s
*
AccountTestService
)
testClaudeAccountConnection
(
c
*
gin
.
Context
,
account
*
model
.
Account
,
modelID
string
)
error
{
ctx
:=
c
.
Request
.
Context
()
// Determine the model to use
testModelID
:=
modelID
if
testModelID
==
""
{
...
...
@@ -222,7 +240,7 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
proxyURL
=
account
.
Proxy
.
URL
()
}
resp
,
err
:=
s
.
claude
Upstream
.
Do
(
req
,
proxyURL
)
resp
,
err
:=
s
.
http
Upstream
.
Do
(
req
,
proxyURL
)
if
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Request failed: %s"
,
err
.
Error
()))
}
...
...
@@ -234,11 +252,153 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
}
// Process SSE stream
return
s
.
processStream
(
c
,
resp
.
Body
)
return
s
.
process
Claude
Stream
(
c
,
resp
.
Body
)
}
// processStream processes the SSE stream from Claude API
func
(
s
*
AccountTestService
)
processStream
(
c
*
gin
.
Context
,
body
io
.
Reader
)
error
{
// testOpenAIAccountConnection tests an OpenAI account's connection
func
(
s
*
AccountTestService
)
testOpenAIAccountConnection
(
c
*
gin
.
Context
,
account
*
model
.
Account
,
modelID
string
)
error
{
ctx
:=
c
.
Request
.
Context
()
// Default to openai.DefaultTestModel for OpenAI testing
testModelID
:=
modelID
if
testModelID
==
""
{
testModelID
=
openai
.
DefaultTestModel
}
// For API Key accounts with model mapping, map the model
if
account
.
Type
==
"apikey"
{
mapping
:=
account
.
GetModelMapping
()
if
len
(
mapping
)
>
0
{
if
mappedModel
,
exists
:=
mapping
[
testModelID
];
exists
{
testModelID
=
mappedModel
}
}
}
// Determine authentication method and API URL
var
authToken
string
var
apiURL
string
var
isOAuth
bool
var
chatgptAccountID
string
if
account
.
IsOAuth
()
{
isOAuth
=
true
// OAuth - use Bearer token with ChatGPT internal API
authToken
=
account
.
GetOpenAIAccessToken
()
if
authToken
==
""
{
return
s
.
sendErrorAndEnd
(
c
,
"No access token available"
)
}
// Check if token is expired and refresh if needed
if
account
.
IsOpenAITokenExpired
()
&&
s
.
openaiOAuthService
!=
nil
{
tokenInfo
,
err
:=
s
.
openaiOAuthService
.
RefreshAccountToken
(
ctx
,
account
)
if
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Failed to refresh token: %s"
,
err
.
Error
()))
}
authToken
=
tokenInfo
.
AccessToken
}
// OAuth uses ChatGPT internal API
apiURL
=
chatgptCodexAPIURL
chatgptAccountID
=
account
.
GetChatGPTAccountID
()
}
else
if
account
.
Type
==
"apikey"
{
// API Key - use Platform API
authToken
=
account
.
GetOpenAIApiKey
()
if
authToken
==
""
{
return
s
.
sendErrorAndEnd
(
c
,
"No API key available"
)
}
baseURL
:=
account
.
GetOpenAIBaseURL
()
if
baseURL
==
""
{
baseURL
=
"https://api.openai.com"
}
apiURL
=
strings
.
TrimSuffix
(
baseURL
,
"/"
)
+
"/v1/responses"
}
else
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Unsupported account type: %s"
,
account
.
Type
))
}
// Set SSE headers
c
.
Writer
.
Header
()
.
Set
(
"Content-Type"
,
"text/event-stream"
)
c
.
Writer
.
Header
()
.
Set
(
"Cache-Control"
,
"no-cache"
)
c
.
Writer
.
Header
()
.
Set
(
"Connection"
,
"keep-alive"
)
c
.
Writer
.
Header
()
.
Set
(
"X-Accel-Buffering"
,
"no"
)
c
.
Writer
.
Flush
()
// Create OpenAI Responses API payload
payload
:=
createOpenAITestPayload
(
testModelID
,
isOAuth
)
payloadBytes
,
_
:=
json
.
Marshal
(
payload
)
// Send test_start event
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"test_start"
,
Model
:
testModelID
})
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
"POST"
,
apiURL
,
bytes
.
NewReader
(
payloadBytes
))
if
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
"Failed to create request"
)
}
// Set common headers
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
Header
.
Set
(
"Authorization"
,
"Bearer "
+
authToken
)
// Set OAuth-specific headers for ChatGPT internal API
if
isOAuth
{
req
.
Host
=
"chatgpt.com"
req
.
Header
.
Set
(
"accept"
,
"text/event-stream"
)
if
chatgptAccountID
!=
""
{
req
.
Header
.
Set
(
"chatgpt-account-id"
,
chatgptAccountID
)
}
}
// Get proxy URL
proxyURL
:=
""
if
account
.
ProxyID
!=
nil
&&
account
.
Proxy
!=
nil
{
proxyURL
=
account
.
Proxy
.
URL
()
}
resp
,
err
:=
s
.
httpUpstream
.
Do
(
req
,
proxyURL
)
if
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Request failed: %s"
,
err
.
Error
()))
}
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
if
resp
.
StatusCode
!=
http
.
StatusOK
{
body
,
_
:=
io
.
ReadAll
(
resp
.
Body
)
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"API returned %d: %s"
,
resp
.
StatusCode
,
string
(
body
)))
}
// Process SSE stream
return
s
.
processOpenAIStream
(
c
,
resp
.
Body
)
}
// createOpenAITestPayload creates a test payload for OpenAI Responses API
func
createOpenAITestPayload
(
modelID
string
,
isOAuth
bool
)
map
[
string
]
any
{
payload
:=
map
[
string
]
any
{
"model"
:
modelID
,
"input"
:
[]
map
[
string
]
any
{
{
"role"
:
"user"
,
"content"
:
[]
map
[
string
]
any
{
{
"type"
:
"input_text"
,
"text"
:
"hi"
,
},
},
},
},
"stream"
:
true
,
}
// OAuth accounts using ChatGPT internal API require store: false and instructions
if
isOAuth
{
payload
[
"store"
]
=
false
payload
[
"instructions"
]
=
openai
.
DefaultInstructions
}
return
payload
}
// processClaudeStream processes the SSE stream from Claude API
func
(
s
*
AccountTestService
)
processClaudeStream
(
c
*
gin
.
Context
,
body
io
.
Reader
)
error
{
reader
:=
bufio
.
NewReader
(
body
)
for
{
...
...
@@ -291,6 +451,59 @@ func (s *AccountTestService) processStream(c *gin.Context, body io.Reader) error
}
}
// processOpenAIStream processes the SSE stream from OpenAI Responses API
func
(
s
*
AccountTestService
)
processOpenAIStream
(
c
*
gin
.
Context
,
body
io
.
Reader
)
error
{
reader
:=
bufio
.
NewReader
(
body
)
for
{
line
,
err
:=
reader
.
ReadString
(
'\n'
)
if
err
!=
nil
{
if
err
==
io
.
EOF
{
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"test_complete"
,
Success
:
true
})
return
nil
}
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Stream read error: %s"
,
err
.
Error
()))
}
line
=
strings
.
TrimSpace
(
line
)
if
line
==
""
||
!
strings
.
HasPrefix
(
line
,
"data: "
)
{
continue
}
jsonStr
:=
strings
.
TrimPrefix
(
line
,
"data: "
)
if
jsonStr
==
"[DONE]"
{
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"test_complete"
,
Success
:
true
})
return
nil
}
var
data
map
[
string
]
any
if
err
:=
json
.
Unmarshal
([]
byte
(
jsonStr
),
&
data
);
err
!=
nil
{
continue
}
eventType
,
_
:=
data
[
"type"
]
.
(
string
)
switch
eventType
{
case
"response.output_text.delta"
:
// OpenAI Responses API uses "delta" field for text content
if
delta
,
ok
:=
data
[
"delta"
]
.
(
string
);
ok
&&
delta
!=
""
{
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
delta
})
}
case
"response.completed"
:
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"test_complete"
,
Success
:
true
})
return
nil
case
"error"
:
errorMsg
:=
"Unknown error"
if
errData
,
ok
:=
data
[
"error"
]
.
(
map
[
string
]
any
);
ok
{
if
msg
,
ok
:=
errData
[
"message"
]
.
(
string
);
ok
{
errorMsg
=
msg
}
}
return
s
.
sendErrorAndEnd
(
c
,
errorMsg
)
}
}
}
// sendEvent sends a SSE event to the client
func
(
s
*
AccountTestService
)
sendEvent
(
c
*
gin
.
Context
,
event
TestEvent
)
{
eventJSON
,
_
:=
json
.
Marshal
(
event
)
...
...
backend/internal/service/gateway_service.go
View file @
6c469b42
...
...
@@ -24,11 +24,6 @@ import (
"github.com/gin-gonic/gin"
)
// ClaudeUpstream handles HTTP requests to Claude API
type
ClaudeUpstream
interface
{
Do
(
req
*
http
.
Request
,
proxyURL
string
)
(
*
http
.
Response
,
error
)
}
const
(
claudeAPIURL
=
"https://api.anthropic.com/v1/messages?beta=true"
claudeAPICountTokensURL
=
"https://api.anthropic.com/v1/messages/count_tokens?beta=true"
...
...
@@ -87,7 +82,7 @@ type GatewayService struct {
rateLimitService
*
RateLimitService
billingCacheService
*
BillingCacheService
identityService
*
IdentityService
claude
Upstream
Claude
Upstream
http
Upstream
ports
.
HTTP
Upstream
}
// NewGatewayService creates a new GatewayService
...
...
@@ -102,7 +97,7 @@ func NewGatewayService(
rateLimitService
*
RateLimitService
,
billingCacheService
*
BillingCacheService
,
identityService
*
IdentityService
,
claude
Upstream
Claude
Upstream
,
http
Upstream
ports
.
HTTP
Upstream
,
)
*
GatewayService
{
return
&
GatewayService
{
accountRepo
:
accountRepo
,
...
...
@@ -115,7 +110,7 @@ func NewGatewayService(
rateLimitService
:
rateLimitService
,
billingCacheService
:
billingCacheService
,
identityService
:
identityService
,
claude
Upstream
:
claude
Upstream
,
http
Upstream
:
http
Upstream
,
}
}
...
...
@@ -285,13 +280,13 @@ func (s *GatewayService) SelectAccountForModel(ctx context.Context, groupID *int
}
}
// 2. 获取可调度账号列表(排除限流和过载的账号)
// 2. 获取可调度账号列表(排除限流和过载的账号
,仅限 Anthropic 平台
)
var
accounts
[]
model
.
Account
var
err
error
if
groupID
!=
nil
{
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByGroupID
(
ctx
,
*
groupID
)
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByGroupID
AndPlatform
(
ctx
,
*
groupID
,
model
.
PlatformAnthropic
)
}
else
{
accounts
,
err
=
s
.
accountRepo
.
ListSchedulable
(
ctx
)
accounts
,
err
=
s
.
accountRepo
.
ListSchedulable
ByPlatform
(
ctx
,
model
.
PlatformAnthropic
)
}
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"query accounts failed: %w"
,
err
)
...
...
@@ -407,7 +402,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *m
}
// 发送请求
resp
,
err
:=
s
.
claude
Upstream
.
Do
(
upstreamReq
,
proxyURL
)
resp
,
err
:=
s
.
http
Upstream
.
Do
(
upstreamReq
,
proxyURL
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"upstream request failed: %w"
,
err
)
}
...
...
@@ -481,7 +476,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
// 设置认证头
if
tokenType
==
"oauth"
{
req
.
Header
.
Set
(
"
A
uthorization"
,
"Bearer "
+
token
)
req
.
Header
.
Set
(
"
a
uthorization"
,
"Bearer "
+
token
)
}
else
{
req
.
Header
.
Set
(
"x-api-key"
,
token
)
}
...
...
@@ -502,8 +497,8 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
}
// 确保必要的headers存在
if
req
.
Header
.
Get
(
"
C
ontent-
T
ype"
)
==
""
{
req
.
Header
.
Set
(
"
C
ontent-
T
ype"
,
"application/json"
)
if
req
.
Header
.
Get
(
"
c
ontent-
t
ype"
)
==
""
{
req
.
Header
.
Set
(
"
c
ontent-
t
ype"
,
"application/json"
)
}
if
req
.
Header
.
Get
(
"anthropic-version"
)
==
""
{
req
.
Header
.
Set
(
"anthropic-version"
,
"2023-06-01"
)
...
...
@@ -982,7 +977,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
}
// 发送请求
resp
,
err
:=
s
.
claude
Upstream
.
Do
(
upstreamReq
,
proxyURL
)
resp
,
err
:=
s
.
http
Upstream
.
Do
(
upstreamReq
,
proxyURL
)
if
err
!=
nil
{
s
.
countTokensError
(
c
,
http
.
StatusBadGateway
,
"upstream_error"
,
"Request failed"
)
return
fmt
.
Errorf
(
"upstream request failed: %w"
,
err
)
...
...
@@ -1049,7 +1044,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
// 设置认证头
if
tokenType
==
"oauth"
{
req
.
Header
.
Set
(
"
A
uthorization"
,
"Bearer "
+
token
)
req
.
Header
.
Set
(
"
a
uthorization"
,
"Bearer "
+
token
)
}
else
{
req
.
Header
.
Set
(
"x-api-key"
,
token
)
}
...
...
@@ -1073,8 +1068,8 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
}
// 确保必要的 headers 存在
if
req
.
Header
.
Get
(
"
C
ontent-
T
ype"
)
==
""
{
req
.
Header
.
Set
(
"
C
ontent-
T
ype"
,
"application/json"
)
if
req
.
Header
.
Get
(
"
c
ontent-
t
ype"
)
==
""
{
req
.
Header
.
Set
(
"
c
ontent-
t
ype"
,
"application/json"
)
}
if
req
.
Header
.
Get
(
"anthropic-version"
)
==
""
{
req
.
Header
.
Set
(
"anthropic-version"
,
"2023-06-01"
)
...
...
backend/internal/service/identity_service.go
View file @
6c469b42
...
...
@@ -114,12 +114,12 @@ func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *ports.Fingerpr
return
}
// 设置
U
ser-
A
gent
// 设置
u
ser-
a
gent
if
fp
.
UserAgent
!=
""
{
req
.
Header
.
Set
(
"
U
ser-
A
gent"
,
fp
.
UserAgent
)
req
.
Header
.
Set
(
"
u
ser-
a
gent"
,
fp
.
UserAgent
)
}
// 设置x-stainless-*头
(使用正确的大小写)
// 设置x-stainless-*头
if
fp
.
StainlessLang
!=
""
{
req
.
Header
.
Set
(
"X-Stainless-Lang"
,
fp
.
StainlessLang
)
}
...
...
backend/internal/service/oauth_service.go
View file @
6c469b42
...
...
@@ -284,3 +284,8 @@ func (s *OAuthService) RefreshAccountToken(ctx context.Context, account *model.A
return
s
.
RefreshToken
(
ctx
,
refreshToken
,
proxyURL
)
}
// Stop stops the session store cleanup goroutine
func
(
s
*
OAuthService
)
Stop
()
{
s
.
sessionStore
.
Stop
()
}
backend/internal/service/openai_gateway_service.go
0 → 100644
View file @
6c469b42
package
service
import
(
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"sub2api/internal/config"
"sub2api/internal/model"
"sub2api/internal/service/ports"
"github.com/gin-gonic/gin"
)
const
(
// ChatGPT internal API for OAuth accounts
chatgptCodexURL
=
"https://chatgpt.com/backend-api/codex/responses"
// OpenAI Platform API for API Key accounts (fallback)
openaiPlatformAPIURL
=
"https://api.openai.com/v1/responses"
openaiStickySessionTTL
=
time
.
Hour
// 粘性会话TTL
)
// OpenAI allowed headers whitelist (for non-OAuth accounts)
var
openaiAllowedHeaders
=
map
[
string
]
bool
{
"accept-language"
:
true
,
"content-type"
:
true
,
"user-agent"
:
true
,
"originator"
:
true
,
"session_id"
:
true
,
}
// OpenAIUsage represents OpenAI API response usage
type
OpenAIUsage
struct
{
InputTokens
int
`json:"input_tokens"`
OutputTokens
int
`json:"output_tokens"`
CacheCreationInputTokens
int
`json:"cache_creation_input_tokens,omitempty"`
CacheReadInputTokens
int
`json:"cache_read_input_tokens,omitempty"`
}
// OpenAIForwardResult represents the result of forwarding
type
OpenAIForwardResult
struct
{
RequestID
string
Usage
OpenAIUsage
Model
string
Stream
bool
Duration
time
.
Duration
FirstTokenMs
*
int
}
// OpenAIGatewayService handles OpenAI API gateway operations
type
OpenAIGatewayService
struct
{
accountRepo
ports
.
AccountRepository
usageLogRepo
ports
.
UsageLogRepository
userRepo
ports
.
UserRepository
userSubRepo
ports
.
UserSubscriptionRepository
cache
ports
.
GatewayCache
cfg
*
config
.
Config
billingService
*
BillingService
rateLimitService
*
RateLimitService
billingCacheService
*
BillingCacheService
httpUpstream
ports
.
HTTPUpstream
}
// NewOpenAIGatewayService creates a new OpenAIGatewayService
func
NewOpenAIGatewayService
(
accountRepo
ports
.
AccountRepository
,
usageLogRepo
ports
.
UsageLogRepository
,
userRepo
ports
.
UserRepository
,
userSubRepo
ports
.
UserSubscriptionRepository
,
cache
ports
.
GatewayCache
,
cfg
*
config
.
Config
,
billingService
*
BillingService
,
rateLimitService
*
RateLimitService
,
billingCacheService
*
BillingCacheService
,
httpUpstream
ports
.
HTTPUpstream
,
)
*
OpenAIGatewayService
{
return
&
OpenAIGatewayService
{
accountRepo
:
accountRepo
,
usageLogRepo
:
usageLogRepo
,
userRepo
:
userRepo
,
userSubRepo
:
userSubRepo
,
cache
:
cache
,
cfg
:
cfg
,
billingService
:
billingService
,
rateLimitService
:
rateLimitService
,
billingCacheService
:
billingCacheService
,
httpUpstream
:
httpUpstream
,
}
}
// GenerateSessionHash generates session hash from header (OpenAI uses session_id header)
func
(
s
*
OpenAIGatewayService
)
GenerateSessionHash
(
c
*
gin
.
Context
)
string
{
sessionID
:=
c
.
GetHeader
(
"session_id"
)
if
sessionID
==
""
{
return
""
}
hash
:=
sha256
.
Sum256
([]
byte
(
sessionID
))
return
hex
.
EncodeToString
(
hash
[
:
])
}
// SelectAccount selects an OpenAI account with sticky session support
func
(
s
*
OpenAIGatewayService
)
SelectAccount
(
ctx
context
.
Context
,
groupID
*
int64
,
sessionHash
string
)
(
*
model
.
Account
,
error
)
{
return
s
.
SelectAccountForModel
(
ctx
,
groupID
,
sessionHash
,
""
)
}
// SelectAccountForModel selects an account supporting the requested model
func
(
s
*
OpenAIGatewayService
)
SelectAccountForModel
(
ctx
context
.
Context
,
groupID
*
int64
,
sessionHash
string
,
requestedModel
string
)
(
*
model
.
Account
,
error
)
{
// 1. Check sticky session
if
sessionHash
!=
""
{
accountID
,
err
:=
s
.
cache
.
GetSessionAccountID
(
ctx
,
"openai:"
+
sessionHash
)
if
err
==
nil
&&
accountID
>
0
{
account
,
err
:=
s
.
accountRepo
.
GetByID
(
ctx
,
accountID
)
if
err
==
nil
&&
account
.
IsSchedulable
()
&&
account
.
IsOpenAI
()
&&
(
requestedModel
==
""
||
account
.
IsModelSupported
(
requestedModel
))
{
// Refresh sticky session TTL
_
=
s
.
cache
.
RefreshSessionTTL
(
ctx
,
"openai:"
+
sessionHash
,
openaiStickySessionTTL
)
return
account
,
nil
}
}
}
// 2. Get schedulable OpenAI accounts
var
accounts
[]
model
.
Account
var
err
error
if
groupID
!=
nil
{
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByGroupIDAndPlatform
(
ctx
,
*
groupID
,
model
.
PlatformOpenAI
)
}
else
{
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByPlatform
(
ctx
,
model
.
PlatformOpenAI
)
}
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"query accounts failed: %w"
,
err
)
}
// 3. Select by priority + LRU
var
selected
*
model
.
Account
for
i
:=
range
accounts
{
acc
:=
&
accounts
[
i
]
// Check model support
if
requestedModel
!=
""
&&
!
acc
.
IsModelSupported
(
requestedModel
)
{
continue
}
if
selected
==
nil
{
selected
=
acc
continue
}
// Lower priority value means higher priority
if
acc
.
Priority
<
selected
.
Priority
{
selected
=
acc
}
else
if
acc
.
Priority
==
selected
.
Priority
{
// Same priority, select least recently used
if
acc
.
LastUsedAt
==
nil
||
(
selected
.
LastUsedAt
!=
nil
&&
acc
.
LastUsedAt
.
Before
(
*
selected
.
LastUsedAt
))
{
selected
=
acc
}
}
}
if
selected
==
nil
{
if
requestedModel
!=
""
{
return
nil
,
fmt
.
Errorf
(
"no available OpenAI accounts supporting model: %s"
,
requestedModel
)
}
return
nil
,
errors
.
New
(
"no available OpenAI accounts"
)
}
// 4. Set sticky session
if
sessionHash
!=
""
{
_
=
s
.
cache
.
SetSessionAccountID
(
ctx
,
"openai:"
+
sessionHash
,
selected
.
ID
,
openaiStickySessionTTL
)
}
return
selected
,
nil
}
// GetAccessToken gets the access token for an OpenAI account
func
(
s
*
OpenAIGatewayService
)
GetAccessToken
(
ctx
context
.
Context
,
account
*
model
.
Account
)
(
string
,
string
,
error
)
{
if
account
.
Type
==
model
.
AccountTypeOAuth
{
accessToken
:=
account
.
GetOpenAIAccessToken
()
if
accessToken
==
""
{
return
""
,
""
,
errors
.
New
(
"access_token not found in credentials"
)
}
return
accessToken
,
"oauth"
,
nil
}
else
if
account
.
Type
==
model
.
AccountTypeApiKey
{
apiKey
:=
account
.
GetOpenAIApiKey
()
if
apiKey
==
""
{
return
""
,
""
,
errors
.
New
(
"api_key not found in credentials"
)
}
return
apiKey
,
"apikey"
,
nil
}
return
""
,
""
,
fmt
.
Errorf
(
"unsupported account type: %s"
,
account
.
Type
)
}
// Forward forwards request to OpenAI API
func
(
s
*
OpenAIGatewayService
)
Forward
(
ctx
context
.
Context
,
c
*
gin
.
Context
,
account
*
model
.
Account
,
body
[]
byte
)
(
*
OpenAIForwardResult
,
error
)
{
startTime
:=
time
.
Now
()
// Parse request body once (avoid multiple parse/serialize cycles)
var
reqBody
map
[
string
]
any
if
err
:=
json
.
Unmarshal
(
body
,
&
reqBody
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"parse request: %w"
,
err
)
}
// Extract model and stream from parsed body
reqModel
,
_
:=
reqBody
[
"model"
]
.
(
string
)
reqStream
,
_
:=
reqBody
[
"stream"
]
.
(
bool
)
// Track if body needs re-serialization
bodyModified
:=
false
originalModel
:=
reqModel
// Apply model mapping
mappedModel
:=
account
.
GetMappedModel
(
reqModel
)
if
mappedModel
!=
reqModel
{
reqBody
[
"model"
]
=
mappedModel
bodyModified
=
true
}
// For OAuth accounts using ChatGPT internal API, add store: false
if
account
.
Type
==
model
.
AccountTypeOAuth
{
reqBody
[
"store"
]
=
false
bodyModified
=
true
}
// Re-serialize body only if modified
if
bodyModified
{
var
err
error
body
,
err
=
json
.
Marshal
(
reqBody
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"serialize request body: %w"
,
err
)
}
}
// Get access token
token
,
_
,
err
:=
s
.
GetAccessToken
(
ctx
,
account
)
if
err
!=
nil
{
return
nil
,
err
}
// Build upstream request
upstreamReq
,
err
:=
s
.
buildUpstreamRequest
(
ctx
,
c
,
account
,
body
,
token
,
reqStream
)
if
err
!=
nil
{
return
nil
,
err
}
// Get proxy URL
proxyURL
:=
""
if
account
.
ProxyID
!=
nil
&&
account
.
Proxy
!=
nil
{
proxyURL
=
account
.
Proxy
.
URL
()
}
// Send request
resp
,
err
:=
s
.
httpUpstream
.
Do
(
upstreamReq
,
proxyURL
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"upstream request failed: %w"
,
err
)
}
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
// Handle error response
if
resp
.
StatusCode
>=
400
{
return
s
.
handleErrorResponse
(
ctx
,
resp
,
c
,
account
)
}
// Handle normal response
var
usage
*
OpenAIUsage
var
firstTokenMs
*
int
if
reqStream
{
streamResult
,
err
:=
s
.
handleStreamingResponse
(
ctx
,
resp
,
c
,
account
,
startTime
,
originalModel
,
mappedModel
)
if
err
!=
nil
{
return
nil
,
err
}
usage
=
streamResult
.
usage
firstTokenMs
=
streamResult
.
firstTokenMs
}
else
{
usage
,
err
=
s
.
handleNonStreamingResponse
(
ctx
,
resp
,
c
,
account
,
originalModel
,
mappedModel
)
if
err
!=
nil
{
return
nil
,
err
}
}
return
&
OpenAIForwardResult
{
RequestID
:
resp
.
Header
.
Get
(
"x-request-id"
),
Usage
:
*
usage
,
Model
:
originalModel
,
Stream
:
reqStream
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
firstTokenMs
,
},
nil
}
func
(
s
*
OpenAIGatewayService
)
buildUpstreamRequest
(
ctx
context
.
Context
,
c
*
gin
.
Context
,
account
*
model
.
Account
,
body
[]
byte
,
token
string
,
isStream
bool
)
(
*
http
.
Request
,
error
)
{
// Determine target URL based on account type
var
targetURL
string
if
account
.
Type
==
model
.
AccountTypeOAuth
{
// OAuth accounts use ChatGPT internal API
targetURL
=
chatgptCodexURL
}
else
if
account
.
Type
==
model
.
AccountTypeApiKey
{
// API Key accounts use Platform API or custom base URL
baseURL
:=
account
.
GetOpenAIBaseURL
()
if
baseURL
!=
""
{
targetURL
=
baseURL
+
"/v1/responses"
}
else
{
targetURL
=
openaiPlatformAPIURL
}
}
else
{
targetURL
=
openaiPlatformAPIURL
}
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
"POST"
,
targetURL
,
bytes
.
NewReader
(
body
))
if
err
!=
nil
{
return
nil
,
err
}
// Set authentication header
req
.
Header
.
Set
(
"authorization"
,
"Bearer "
+
token
)
// Set headers specific to OAuth accounts (ChatGPT internal API)
if
account
.
Type
==
model
.
AccountTypeOAuth
{
// Required: set Host for ChatGPT API (must use req.Host, not Header.Set)
req
.
Host
=
"chatgpt.com"
// Required: set chatgpt-account-id header
chatgptAccountID
:=
account
.
GetChatGPTAccountID
()
if
chatgptAccountID
!=
""
{
req
.
Header
.
Set
(
"chatgpt-account-id"
,
chatgptAccountID
)
}
// Set accept header based on stream mode
if
isStream
{
req
.
Header
.
Set
(
"accept"
,
"text/event-stream"
)
}
else
{
req
.
Header
.
Set
(
"accept"
,
"application/json"
)
}
}
// Whitelist passthrough headers
for
key
,
values
:=
range
c
.
Request
.
Header
{
lowerKey
:=
strings
.
ToLower
(
key
)
if
openaiAllowedHeaders
[
lowerKey
]
{
for
_
,
v
:=
range
values
{
req
.
Header
.
Add
(
key
,
v
)
}
}
}
// Apply custom User-Agent if configured
customUA
:=
account
.
GetOpenAIUserAgent
()
if
customUA
!=
""
{
req
.
Header
.
Set
(
"user-agent"
,
customUA
)
}
// Ensure required headers exist
if
req
.
Header
.
Get
(
"content-type"
)
==
""
{
req
.
Header
.
Set
(
"content-type"
,
"application/json"
)
}
return
req
,
nil
}
func
(
s
*
OpenAIGatewayService
)
handleErrorResponse
(
ctx
context
.
Context
,
resp
*
http
.
Response
,
c
*
gin
.
Context
,
account
*
model
.
Account
)
(
*
OpenAIForwardResult
,
error
)
{
body
,
_
:=
io
.
ReadAll
(
resp
.
Body
)
// Check custom error codes
if
!
account
.
ShouldHandleErrorCode
(
resp
.
StatusCode
)
{
c
.
JSON
(
http
.
StatusInternalServerError
,
gin
.
H
{
"error"
:
gin
.
H
{
"type"
:
"upstream_error"
,
"message"
:
"Upstream gateway error"
,
},
})
return
nil
,
fmt
.
Errorf
(
"upstream error: %d (not in custom error codes)"
,
resp
.
StatusCode
)
}
// Handle upstream error (mark account status)
s
.
rateLimitService
.
HandleUpstreamError
(
ctx
,
account
,
resp
.
StatusCode
,
resp
.
Header
,
body
)
// Return appropriate error response
var
errType
,
errMsg
string
var
statusCode
int
switch
resp
.
StatusCode
{
case
401
:
statusCode
=
http
.
StatusBadGateway
errType
=
"upstream_error"
errMsg
=
"Upstream authentication failed, please contact administrator"
case
403
:
statusCode
=
http
.
StatusBadGateway
errType
=
"upstream_error"
errMsg
=
"Upstream access forbidden, please contact administrator"
case
429
:
statusCode
=
http
.
StatusTooManyRequests
errType
=
"rate_limit_error"
errMsg
=
"Upstream rate limit exceeded, please retry later"
default
:
statusCode
=
http
.
StatusBadGateway
errType
=
"upstream_error"
errMsg
=
"Upstream request failed"
}
c
.
JSON
(
statusCode
,
gin
.
H
{
"error"
:
gin
.
H
{
"type"
:
errType
,
"message"
:
errMsg
,
},
})
return
nil
,
fmt
.
Errorf
(
"upstream error: %d"
,
resp
.
StatusCode
)
}
// openaiStreamingResult streaming response result
type
openaiStreamingResult
struct
{
usage
*
OpenAIUsage
firstTokenMs
*
int
}
func
(
s
*
OpenAIGatewayService
)
handleStreamingResponse
(
ctx
context
.
Context
,
resp
*
http
.
Response
,
c
*
gin
.
Context
,
account
*
model
.
Account
,
startTime
time
.
Time
,
originalModel
,
mappedModel
string
)
(
*
openaiStreamingResult
,
error
)
{
// Set SSE response headers
c
.
Header
(
"Content-Type"
,
"text/event-stream"
)
c
.
Header
(
"Cache-Control"
,
"no-cache"
)
c
.
Header
(
"Connection"
,
"keep-alive"
)
c
.
Header
(
"X-Accel-Buffering"
,
"no"
)
// Pass through other headers
if
v
:=
resp
.
Header
.
Get
(
"x-request-id"
);
v
!=
""
{
c
.
Header
(
"x-request-id"
,
v
)
}
w
:=
c
.
Writer
flusher
,
ok
:=
w
.
(
http
.
Flusher
)
if
!
ok
{
return
nil
,
errors
.
New
(
"streaming not supported"
)
}
usage
:=
&
OpenAIUsage
{}
var
firstTokenMs
*
int
scanner
:=
bufio
.
NewScanner
(
resp
.
Body
)
scanner
.
Buffer
(
make
([]
byte
,
64
*
1024
),
1024
*
1024
)
needModelReplace
:=
originalModel
!=
mappedModel
for
scanner
.
Scan
()
{
line
:=
scanner
.
Text
()
// Replace model in response if needed
if
needModelReplace
&&
strings
.
HasPrefix
(
line
,
"data: "
)
{
line
=
s
.
replaceModelInSSELine
(
line
,
mappedModel
,
originalModel
)
}
// Forward line
if
_
,
err
:=
fmt
.
Fprintf
(
w
,
"%s
\n
"
,
line
);
err
!=
nil
{
return
&
openaiStreamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
},
err
}
flusher
.
Flush
()
// Parse usage data
if
strings
.
HasPrefix
(
line
,
"data: "
)
{
data
:=
line
[
6
:
]
// Record first token time
if
firstTokenMs
==
nil
&&
data
!=
""
&&
data
!=
"[DONE]"
{
ms
:=
int
(
time
.
Since
(
startTime
)
.
Milliseconds
())
firstTokenMs
=
&
ms
}
s
.
parseSSEUsage
(
data
,
usage
)
}
}
if
err
:=
scanner
.
Err
();
err
!=
nil
{
return
&
openaiStreamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
},
fmt
.
Errorf
(
"stream read error: %w"
,
err
)
}
return
&
openaiStreamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
},
nil
}
func
(
s
*
OpenAIGatewayService
)
replaceModelInSSELine
(
line
,
fromModel
,
toModel
string
)
string
{
data
:=
line
[
6
:
]
if
data
==
""
||
data
==
"[DONE]"
{
return
line
}
var
event
map
[
string
]
any
if
err
:=
json
.
Unmarshal
([]
byte
(
data
),
&
event
);
err
!=
nil
{
return
line
}
// Replace model in response
if
m
,
ok
:=
event
[
"model"
]
.
(
string
);
ok
&&
m
==
fromModel
{
event
[
"model"
]
=
toModel
newData
,
err
:=
json
.
Marshal
(
event
)
if
err
!=
nil
{
return
line
}
return
"data: "
+
string
(
newData
)
}
// Check nested response
if
response
,
ok
:=
event
[
"response"
]
.
(
map
[
string
]
any
);
ok
{
if
m
,
ok
:=
response
[
"model"
]
.
(
string
);
ok
&&
m
==
fromModel
{
response
[
"model"
]
=
toModel
newData
,
err
:=
json
.
Marshal
(
event
)
if
err
!=
nil
{
return
line
}
return
"data: "
+
string
(
newData
)
}
}
return
line
}
func
(
s
*
OpenAIGatewayService
)
parseSSEUsage
(
data
string
,
usage
*
OpenAIUsage
)
{
// Parse response.completed event for usage (OpenAI Responses format)
var
event
struct
{
Type
string
`json:"type"`
Response
struct
{
Usage
struct
{
InputTokens
int
`json:"input_tokens"`
OutputTokens
int
`json:"output_tokens"`
InputTokenDetails
struct
{
CachedTokens
int
`json:"cached_tokens"`
}
`json:"input_tokens_details"`
}
`json:"usage"`
}
`json:"response"`
}
if
json
.
Unmarshal
([]
byte
(
data
),
&
event
)
==
nil
&&
event
.
Type
==
"response.completed"
{
usage
.
InputTokens
=
event
.
Response
.
Usage
.
InputTokens
usage
.
OutputTokens
=
event
.
Response
.
Usage
.
OutputTokens
usage
.
CacheReadInputTokens
=
event
.
Response
.
Usage
.
InputTokenDetails
.
CachedTokens
}
}
func
(
s
*
OpenAIGatewayService
)
handleNonStreamingResponse
(
ctx
context
.
Context
,
resp
*
http
.
Response
,
c
*
gin
.
Context
,
account
*
model
.
Account
,
originalModel
,
mappedModel
string
)
(
*
OpenAIUsage
,
error
)
{
body
,
err
:=
io
.
ReadAll
(
resp
.
Body
)
if
err
!=
nil
{
return
nil
,
err
}
// Parse usage
var
response
struct
{
Usage
struct
{
InputTokens
int
`json:"input_tokens"`
OutputTokens
int
`json:"output_tokens"`
InputTokenDetails
struct
{
CachedTokens
int
`json:"cached_tokens"`
}
`json:"input_tokens_details"`
}
`json:"usage"`
}
if
err
:=
json
.
Unmarshal
(
body
,
&
response
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"parse response: %w"
,
err
)
}
usage
:=
&
OpenAIUsage
{
InputTokens
:
response
.
Usage
.
InputTokens
,
OutputTokens
:
response
.
Usage
.
OutputTokens
,
CacheReadInputTokens
:
response
.
Usage
.
InputTokenDetails
.
CachedTokens
,
}
// Replace model in response if needed
if
originalModel
!=
mappedModel
{
body
=
s
.
replaceModelInResponseBody
(
body
,
mappedModel
,
originalModel
)
}
// Pass through headers
for
key
,
values
:=
range
resp
.
Header
{
for
_
,
value
:=
range
values
{
c
.
Header
(
key
,
value
)
}
}
c
.
Data
(
resp
.
StatusCode
,
"application/json"
,
body
)
return
usage
,
nil
}
func
(
s
*
OpenAIGatewayService
)
replaceModelInResponseBody
(
body
[]
byte
,
fromModel
,
toModel
string
)
[]
byte
{
var
resp
map
[
string
]
any
if
err
:=
json
.
Unmarshal
(
body
,
&
resp
);
err
!=
nil
{
return
body
}
model
,
ok
:=
resp
[
"model"
]
.
(
string
)
if
!
ok
||
model
!=
fromModel
{
return
body
}
resp
[
"model"
]
=
toModel
newBody
,
err
:=
json
.
Marshal
(
resp
)
if
err
!=
nil
{
return
body
}
return
newBody
}
// OpenAIRecordUsageInput input for recording usage
type
OpenAIRecordUsageInput
struct
{
Result
*
OpenAIForwardResult
ApiKey
*
model
.
ApiKey
User
*
model
.
User
Account
*
model
.
Account
Subscription
*
model
.
UserSubscription
}
// RecordUsage records usage and deducts balance
func
(
s
*
OpenAIGatewayService
)
RecordUsage
(
ctx
context
.
Context
,
input
*
OpenAIRecordUsageInput
)
error
{
result
:=
input
.
Result
apiKey
:=
input
.
ApiKey
user
:=
input
.
User
account
:=
input
.
Account
subscription
:=
input
.
Subscription
// Calculate cost
tokens
:=
UsageTokens
{
InputTokens
:
result
.
Usage
.
InputTokens
,
OutputTokens
:
result
.
Usage
.
OutputTokens
,
CacheCreationTokens
:
result
.
Usage
.
CacheCreationInputTokens
,
CacheReadTokens
:
result
.
Usage
.
CacheReadInputTokens
,
}
// Get rate multiplier
multiplier
:=
s
.
cfg
.
Default
.
RateMultiplier
if
apiKey
.
GroupID
!=
nil
&&
apiKey
.
Group
!=
nil
{
multiplier
=
apiKey
.
Group
.
RateMultiplier
}
cost
,
err
:=
s
.
billingService
.
CalculateCost
(
result
.
Model
,
tokens
,
multiplier
)
if
err
!=
nil
{
cost
=
&
CostBreakdown
{
ActualCost
:
0
}
}
// Determine billing type
isSubscriptionBilling
:=
subscription
!=
nil
&&
apiKey
.
Group
!=
nil
&&
apiKey
.
Group
.
IsSubscriptionType
()
billingType
:=
model
.
BillingTypeBalance
if
isSubscriptionBilling
{
billingType
=
model
.
BillingTypeSubscription
}
// Create usage log
durationMs
:=
int
(
result
.
Duration
.
Milliseconds
())
usageLog
:=
&
model
.
UsageLog
{
UserID
:
user
.
ID
,
ApiKeyID
:
apiKey
.
ID
,
AccountID
:
account
.
ID
,
RequestID
:
result
.
RequestID
,
Model
:
result
.
Model
,
InputTokens
:
result
.
Usage
.
InputTokens
,
OutputTokens
:
result
.
Usage
.
OutputTokens
,
CacheCreationTokens
:
result
.
Usage
.
CacheCreationInputTokens
,
CacheReadTokens
:
result
.
Usage
.
CacheReadInputTokens
,
InputCost
:
cost
.
InputCost
,
OutputCost
:
cost
.
OutputCost
,
CacheCreationCost
:
cost
.
CacheCreationCost
,
CacheReadCost
:
cost
.
CacheReadCost
,
TotalCost
:
cost
.
TotalCost
,
ActualCost
:
cost
.
ActualCost
,
RateMultiplier
:
multiplier
,
BillingType
:
billingType
,
Stream
:
result
.
Stream
,
DurationMs
:
&
durationMs
,
FirstTokenMs
:
result
.
FirstTokenMs
,
CreatedAt
:
time
.
Now
(),
}
if
apiKey
.
GroupID
!=
nil
{
usageLog
.
GroupID
=
apiKey
.
GroupID
}
if
subscription
!=
nil
{
usageLog
.
SubscriptionID
=
&
subscription
.
ID
}
_
=
s
.
usageLogRepo
.
Create
(
ctx
,
usageLog
)
// Deduct based on billing type
if
isSubscriptionBilling
{
if
cost
.
TotalCost
>
0
{
_
=
s
.
userSubRepo
.
IncrementUsage
(
ctx
,
subscription
.
ID
,
cost
.
TotalCost
)
go
func
()
{
cacheCtx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
5
*
time
.
Second
)
defer
cancel
()
_
=
s
.
billingCacheService
.
UpdateSubscriptionUsage
(
cacheCtx
,
user
.
ID
,
*
apiKey
.
GroupID
,
cost
.
TotalCost
)
}()
}
}
else
{
if
cost
.
ActualCost
>
0
{
_
=
s
.
userRepo
.
DeductBalance
(
ctx
,
user
.
ID
,
cost
.
ActualCost
)
go
func
()
{
cacheCtx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
5
*
time
.
Second
)
defer
cancel
()
_
=
s
.
billingCacheService
.
DeductBalanceCache
(
cacheCtx
,
user
.
ID
,
cost
.
ActualCost
)
}()
}
}
// Update account last used
_
=
s
.
accountRepo
.
UpdateLastUsed
(
ctx
,
account
.
ID
)
return
nil
}
backend/internal/service/openai_oauth_service.go
0 → 100644
View file @
6c469b42
package
service
import
(
"context"
"fmt"
"time"
"sub2api/internal/model"
"sub2api/internal/pkg/openai"
"sub2api/internal/service/ports"
)
// OpenAIOAuthService handles OpenAI OAuth authentication flows
type
OpenAIOAuthService
struct
{
sessionStore
*
openai
.
SessionStore
proxyRepo
ports
.
ProxyRepository
oauthClient
ports
.
OpenAIOAuthClient
}
// NewOpenAIOAuthService creates a new OpenAI OAuth service
func
NewOpenAIOAuthService
(
proxyRepo
ports
.
ProxyRepository
,
oauthClient
ports
.
OpenAIOAuthClient
)
*
OpenAIOAuthService
{
return
&
OpenAIOAuthService
{
sessionStore
:
openai
.
NewSessionStore
(),
proxyRepo
:
proxyRepo
,
oauthClient
:
oauthClient
,
}
}
// OpenAIAuthURLResult contains the authorization URL and session info
type
OpenAIAuthURLResult
struct
{
AuthURL
string
`json:"auth_url"`
SessionID
string
`json:"session_id"`
}
// GenerateAuthURL generates an OpenAI OAuth authorization URL
func
(
s
*
OpenAIOAuthService
)
GenerateAuthURL
(
ctx
context
.
Context
,
proxyID
*
int64
,
redirectURI
string
)
(
*
OpenAIAuthURLResult
,
error
)
{
// Generate PKCE values
state
,
err
:=
openai
.
GenerateState
()
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"failed to generate state: %w"
,
err
)
}
codeVerifier
,
err
:=
openai
.
GenerateCodeVerifier
()
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"failed to generate code verifier: %w"
,
err
)
}
codeChallenge
:=
openai
.
GenerateCodeChallenge
(
codeVerifier
)
// Generate session ID
sessionID
,
err
:=
openai
.
GenerateSessionID
()
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"failed to generate session ID: %w"
,
err
)
}
// Get proxy URL if specified
var
proxyURL
string
if
proxyID
!=
nil
{
proxy
,
err
:=
s
.
proxyRepo
.
GetByID
(
ctx
,
*
proxyID
)
if
err
==
nil
&&
proxy
!=
nil
{
proxyURL
=
proxy
.
URL
()
}
}
// Use default redirect URI if not specified
if
redirectURI
==
""
{
redirectURI
=
openai
.
DefaultRedirectURI
}
// Store session
session
:=
&
openai
.
OAuthSession
{
State
:
state
,
CodeVerifier
:
codeVerifier
,
RedirectURI
:
redirectURI
,
ProxyURL
:
proxyURL
,
CreatedAt
:
time
.
Now
(),
}
s
.
sessionStore
.
Set
(
sessionID
,
session
)
// Build authorization URL
authURL
:=
openai
.
BuildAuthorizationURL
(
state
,
codeChallenge
,
redirectURI
)
return
&
OpenAIAuthURLResult
{
AuthURL
:
authURL
,
SessionID
:
sessionID
,
},
nil
}
// OpenAIExchangeCodeInput represents the input for code exchange
type
OpenAIExchangeCodeInput
struct
{
SessionID
string
Code
string
RedirectURI
string
ProxyID
*
int64
}
// OpenAITokenInfo represents the token information for OpenAI
type
OpenAITokenInfo
struct
{
AccessToken
string
`json:"access_token"`
RefreshToken
string
`json:"refresh_token"`
IDToken
string
`json:"id_token,omitempty"`
ExpiresIn
int64
`json:"expires_in"`
ExpiresAt
int64
`json:"expires_at"`
Email
string
`json:"email,omitempty"`
ChatGPTAccountID
string
`json:"chatgpt_account_id,omitempty"`
ChatGPTUserID
string
`json:"chatgpt_user_id,omitempty"`
OrganizationID
string
`json:"organization_id,omitempty"`
}
// ExchangeCode exchanges authorization code for tokens
func
(
s
*
OpenAIOAuthService
)
ExchangeCode
(
ctx
context
.
Context
,
input
*
OpenAIExchangeCodeInput
)
(
*
OpenAITokenInfo
,
error
)
{
// Get session
session
,
ok
:=
s
.
sessionStore
.
Get
(
input
.
SessionID
)
if
!
ok
{
return
nil
,
fmt
.
Errorf
(
"session not found or expired"
)
}
// Get proxy URL
proxyURL
:=
session
.
ProxyURL
if
input
.
ProxyID
!=
nil
{
proxy
,
err
:=
s
.
proxyRepo
.
GetByID
(
ctx
,
*
input
.
ProxyID
)
if
err
==
nil
&&
proxy
!=
nil
{
proxyURL
=
proxy
.
URL
()
}
}
// Use redirect URI from session or input
redirectURI
:=
session
.
RedirectURI
if
input
.
RedirectURI
!=
""
{
redirectURI
=
input
.
RedirectURI
}
// Exchange code for token
tokenResp
,
err
:=
s
.
oauthClient
.
ExchangeCode
(
ctx
,
input
.
Code
,
session
.
CodeVerifier
,
redirectURI
,
proxyURL
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"failed to exchange code: %w"
,
err
)
}
// Parse ID token to get user info
var
userInfo
*
openai
.
UserInfo
if
tokenResp
.
IDToken
!=
""
{
claims
,
err
:=
openai
.
ParseIDToken
(
tokenResp
.
IDToken
)
if
err
==
nil
{
userInfo
=
claims
.
GetUserInfo
()
}
}
// Delete session after successful exchange
s
.
sessionStore
.
Delete
(
input
.
SessionID
)
tokenInfo
:=
&
OpenAITokenInfo
{
AccessToken
:
tokenResp
.
AccessToken
,
RefreshToken
:
tokenResp
.
RefreshToken
,
IDToken
:
tokenResp
.
IDToken
,
ExpiresIn
:
int64
(
tokenResp
.
ExpiresIn
),
ExpiresAt
:
time
.
Now
()
.
Unix
()
+
int64
(
tokenResp
.
ExpiresIn
),
}
if
userInfo
!=
nil
{
tokenInfo
.
Email
=
userInfo
.
Email
tokenInfo
.
ChatGPTAccountID
=
userInfo
.
ChatGPTAccountID
tokenInfo
.
ChatGPTUserID
=
userInfo
.
ChatGPTUserID
tokenInfo
.
OrganizationID
=
userInfo
.
OrganizationID
}
return
tokenInfo
,
nil
}
// RefreshToken refreshes an OpenAI OAuth token
func
(
s
*
OpenAIOAuthService
)
RefreshToken
(
ctx
context
.
Context
,
refreshToken
string
,
proxyURL
string
)
(
*
OpenAITokenInfo
,
error
)
{
tokenResp
,
err
:=
s
.
oauthClient
.
RefreshToken
(
ctx
,
refreshToken
,
proxyURL
)
if
err
!=
nil
{
return
nil
,
err
}
// Parse ID token to get user info
var
userInfo
*
openai
.
UserInfo
if
tokenResp
.
IDToken
!=
""
{
claims
,
err
:=
openai
.
ParseIDToken
(
tokenResp
.
IDToken
)
if
err
==
nil
{
userInfo
=
claims
.
GetUserInfo
()
}
}
tokenInfo
:=
&
OpenAITokenInfo
{
AccessToken
:
tokenResp
.
AccessToken
,
RefreshToken
:
tokenResp
.
RefreshToken
,
IDToken
:
tokenResp
.
IDToken
,
ExpiresIn
:
int64
(
tokenResp
.
ExpiresIn
),
ExpiresAt
:
time
.
Now
()
.
Unix
()
+
int64
(
tokenResp
.
ExpiresIn
),
}
if
userInfo
!=
nil
{
tokenInfo
.
Email
=
userInfo
.
Email
tokenInfo
.
ChatGPTAccountID
=
userInfo
.
ChatGPTAccountID
tokenInfo
.
ChatGPTUserID
=
userInfo
.
ChatGPTUserID
tokenInfo
.
OrganizationID
=
userInfo
.
OrganizationID
}
return
tokenInfo
,
nil
}
// RefreshAccountToken refreshes token for an OpenAI account
func
(
s
*
OpenAIOAuthService
)
RefreshAccountToken
(
ctx
context
.
Context
,
account
*
model
.
Account
)
(
*
OpenAITokenInfo
,
error
)
{
if
!
account
.
IsOpenAI
()
{
return
nil
,
fmt
.
Errorf
(
"account is not an OpenAI account"
)
}
refreshToken
:=
account
.
GetOpenAIRefreshToken
()
if
refreshToken
==
""
{
return
nil
,
fmt
.
Errorf
(
"no refresh token available"
)
}
var
proxyURL
string
if
account
.
ProxyID
!=
nil
{
proxy
,
err
:=
s
.
proxyRepo
.
GetByID
(
ctx
,
*
account
.
ProxyID
)
if
err
==
nil
&&
proxy
!=
nil
{
proxyURL
=
proxy
.
URL
()
}
}
return
s
.
RefreshToken
(
ctx
,
refreshToken
,
proxyURL
)
}
// BuildAccountCredentials builds credentials map from token info
func
(
s
*
OpenAIOAuthService
)
BuildAccountCredentials
(
tokenInfo
*
OpenAITokenInfo
)
map
[
string
]
any
{
expiresAt
:=
time
.
Unix
(
tokenInfo
.
ExpiresAt
,
0
)
.
Format
(
time
.
RFC3339
)
creds
:=
map
[
string
]
any
{
"access_token"
:
tokenInfo
.
AccessToken
,
"refresh_token"
:
tokenInfo
.
RefreshToken
,
"expires_at"
:
expiresAt
,
}
if
tokenInfo
.
IDToken
!=
""
{
creds
[
"id_token"
]
=
tokenInfo
.
IDToken
}
if
tokenInfo
.
Email
!=
""
{
creds
[
"email"
]
=
tokenInfo
.
Email
}
if
tokenInfo
.
ChatGPTAccountID
!=
""
{
creds
[
"chatgpt_account_id"
]
=
tokenInfo
.
ChatGPTAccountID
}
if
tokenInfo
.
ChatGPTUserID
!=
""
{
creds
[
"chatgpt_user_id"
]
=
tokenInfo
.
ChatGPTUserID
}
if
tokenInfo
.
OrganizationID
!=
""
{
creds
[
"organization_id"
]
=
tokenInfo
.
OrganizationID
}
return
creds
}
// Stop stops the session store cleanup goroutine
func
(
s
*
OpenAIOAuthService
)
Stop
()
{
s
.
sessionStore
.
Stop
()
}
backend/internal/service/ports/account.go
View file @
6c469b42
...
...
@@ -27,6 +27,8 @@ type AccountRepository interface {
ListSchedulable
(
ctx
context
.
Context
)
([]
model
.
Account
,
error
)
ListSchedulableByGroupID
(
ctx
context
.
Context
,
groupID
int64
)
([]
model
.
Account
,
error
)
ListSchedulableByPlatform
(
ctx
context
.
Context
,
platform
string
)
([]
model
.
Account
,
error
)
ListSchedulableByGroupIDAndPlatform
(
ctx
context
.
Context
,
groupID
int64
,
platform
string
)
([]
model
.
Account
,
error
)
SetRateLimited
(
ctx
context
.
Context
,
id
int64
,
resetAt
time
.
Time
)
error
SetOverloaded
(
ctx
context
.
Context
,
id
int64
,
until
time
.
Time
)
error
...
...
backend/internal/service/ports/http_upstream.go
0 → 100644
View file @
6c469b42
package
ports
import
"net/http"
// HTTPUpstream interface for making HTTP requests to upstream APIs (Claude, OpenAI, etc.)
// This is a generic interface that can be used for any HTTP-based upstream service.
type
HTTPUpstream
interface
{
Do
(
req
*
http
.
Request
,
proxyURL
string
)
(
*
http
.
Response
,
error
)
}
backend/internal/service/ports/openai_oauth.go
0 → 100644
View file @
6c469b42
package
ports
import
(
"context"
"sub2api/internal/pkg/openai"
)
// OpenAIOAuthClient interface for OpenAI OAuth operations
type
OpenAIOAuthClient
interface
{
ExchangeCode
(
ctx
context
.
Context
,
code
,
codeVerifier
,
redirectURI
,
proxyURL
string
)
(
*
openai
.
TokenResponse
,
error
)
RefreshToken
(
ctx
context
.
Context
,
refreshToken
,
proxyURL
string
)
(
*
openai
.
TokenResponse
,
error
)
}
backend/internal/service/service.go
View file @
6c469b42
...
...
@@ -15,7 +15,9 @@ type Services struct {
BillingCache
*
BillingCacheService
Admin
AdminService
Gateway
*
GatewayService
OpenAIGateway
*
OpenAIGatewayService
OAuth
*
OAuthService
OpenAIOAuth
*
OpenAIOAuthService
RateLimit
*
RateLimitService
AccountUsage
*
AccountUsageService
AccountTest
*
AccountTestService
...
...
backend/internal/service/token_refresh_service.go
View file @
6c469b42
...
...
@@ -27,6 +27,7 @@ type TokenRefreshService struct {
func
NewTokenRefreshService
(
accountRepo
ports
.
AccountRepository
,
oauthService
*
OAuthService
,
openaiOAuthService
*
OpenAIOAuthService
,
cfg
*
config
.
Config
,
)
*
TokenRefreshService
{
s
:=
&
TokenRefreshService
{
...
...
@@ -38,9 +39,7 @@ func NewTokenRefreshService(
// 注册平台特定的刷新器
s
.
refreshers
=
[]
TokenRefresher
{
NewClaudeTokenRefresher
(
oauthService
),
// 未来可以添加其他平台的刷新器:
// NewOpenAITokenRefresher(...),
// NewGeminiTokenRefresher(...),
NewOpenAITokenRefresher
(
openaiOAuthService
),
}
return
s
...
...
backend/internal/service/token_refresher.go
View file @
6c469b42
...
...
@@ -88,3 +88,54 @@ func (r *ClaudeTokenRefresher) Refresh(ctx context.Context, account *model.Accou
return
newCredentials
,
nil
}
// OpenAITokenRefresher 处理 OpenAI OAuth token刷新
type
OpenAITokenRefresher
struct
{
openaiOAuthService
*
OpenAIOAuthService
}
// NewOpenAITokenRefresher 创建 OpenAI token刷新器
func
NewOpenAITokenRefresher
(
openaiOAuthService
*
OpenAIOAuthService
)
*
OpenAITokenRefresher
{
return
&
OpenAITokenRefresher
{
openaiOAuthService
:
openaiOAuthService
,
}
}
// CanRefresh 检查是否能处理此账号
// 只处理 openai 平台的 oauth 类型账号
func
(
r
*
OpenAITokenRefresher
)
CanRefresh
(
account
*
model
.
Account
)
bool
{
return
account
.
Platform
==
model
.
PlatformOpenAI
&&
account
.
Type
==
model
.
AccountTypeOAuth
}
// NeedsRefresh 检查token是否需要刷新
// 基于 expires_at 字段判断是否在刷新窗口内
func
(
r
*
OpenAITokenRefresher
)
NeedsRefresh
(
account
*
model
.
Account
,
refreshWindow
time
.
Duration
)
bool
{
expiresAt
:=
account
.
GetOpenAITokenExpiresAt
()
if
expiresAt
==
nil
{
return
false
}
return
time
.
Until
(
*
expiresAt
)
<
refreshWindow
}
// Refresh 执行token刷新
// 保留原有credentials中的所有字段,只更新token相关字段
func
(
r
*
OpenAITokenRefresher
)
Refresh
(
ctx
context
.
Context
,
account
*
model
.
Account
)
(
map
[
string
]
any
,
error
)
{
tokenInfo
,
err
:=
r
.
openaiOAuthService
.
RefreshAccountToken
(
ctx
,
account
)
if
err
!=
nil
{
return
nil
,
err
}
// 使用服务提供的方法构建新凭证,并保留原有字段
newCredentials
:=
r
.
openaiOAuthService
.
BuildAccountCredentials
(
tokenInfo
)
// 保留原有credentials中非token相关字段
for
k
,
v
:=
range
account
.
Credentials
{
if
_
,
exists
:=
newCredentials
[
k
];
!
exists
{
newCredentials
[
k
]
=
v
}
}
return
newCredentials
,
nil
}
backend/internal/service/wire.go
View file @
6c469b42
...
...
@@ -37,9 +37,10 @@ func ProvideEmailQueueService(emailService *EmailService) *EmailQueueService {
func
ProvideTokenRefreshService
(
accountRepo
ports
.
AccountRepository
,
oauthService
*
OAuthService
,
openaiOAuthService
*
OpenAIOAuthService
,
cfg
*
config
.
Config
,
)
*
TokenRefreshService
{
svc
:=
NewTokenRefreshService
(
accountRepo
,
oauthService
,
cfg
)
svc
:=
NewTokenRefreshService
(
accountRepo
,
oauthService
,
openaiOAuthService
,
cfg
)
svc
.
Start
()
return
svc
}
...
...
@@ -60,7 +61,9 @@ var ProviderSet = wire.NewSet(
NewBillingCacheService
,
NewAdminService
,
NewGatewayService
,
NewOpenAIGatewayService
,
NewOAuthService
,
NewOpenAIOAuthService
,
NewRateLimitService
,
NewAccountUsageService
,
NewAccountTestService
,
...
...
frontend/src/components/account/AccountUsageCell.vue
View file @
6c469b42
<
template
>
<div
v-if=
"
account.type === 'oauth' || account.type === 'setup-token'
"
>
<!-- OAuth accounts: fetch real usage data -->
<template
v-if=
"account.type === 'oauth'"
>
<div
v-if=
"
showUsageWindows
"
>
<!--
Anthropic
OAuth accounts: fetch real usage data -->
<template
v-if=
"
account.platform === 'anthropic' &&
account.type === 'oauth'"
>
<!-- Loading state -->
<div
v-if=
"loading"
class=
"space-y-1.5"
>
<div
class=
"flex items-center gap-1"
>
...
...
@@ -63,20 +63,25 @@
</div>
</
template
>
<!-- Setup Token accounts: show time-based window progress -->
<
template
v-else-if=
"account.type === 'setup-token'"
>
<!--
Anthropic
Setup Token accounts: show time-based window progress -->
<
template
v-else-if=
"
account.platform === 'anthropic' &&
account.type === 'setup-token'"
>
<SetupTokenTimeWindow
:account=
"account"
/>
</
template
>
<!-- OpenAI accounts: no usage window API, show dash -->
<
template
v-else
>
<div
class=
"text-xs text-gray-400"
>
-
</div>
</
template
>
</div>
<!-- Non-OAuth accounts -->
<!-- Non-OAuth
/Setup-Token
accounts -->
<div
v-else
class=
"text-xs text-gray-400"
>
-
</div>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
onMounted
}
from
'
vue
'
import
{
ref
,
computed
,
onMounted
}
from
'
vue
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
AccountUsageInfo
}
from
'
@/types
'
import
UsageProgressBar
from
'
./UsageProgressBar.vue
'
...
...
@@ -90,9 +95,15 @@ const loading = ref(false)
const
error
=
ref
<
string
|
null
>
(
null
)
const
usageInfo
=
ref
<
AccountUsageInfo
|
null
>
(
null
)
// Show usage windows for OAuth and Setup Token accounts
const
showUsageWindows
=
computed
(()
=>
props
.
account
.
type
===
'
oauth
'
||
props
.
account
.
type
===
'
setup-token
'
)
const
loadUsage
=
async
()
=>
{
// Only fetch usage for OAuth accounts (Setup Token uses local time-based calculation)
if
(
props
.
account
.
type
!==
'
oauth
'
)
return
// Only fetch usage for Anthropic OAuth accounts
// OpenAI doesn't have a usage window API - usage is updated from response headers during forwarding
if
(
props
.
account
.
platform
!==
'
anthropic
'
||
props
.
account
.
type
!==
'
oauth
'
)
return
loading
.
value
=
true
error
.
value
=
null
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
6c469b42
...
...
@@ -47,83 +47,161 @@
/>
</div>
<!-- Platform Selection - Segmented Control Style -->
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.platform
'
)
}}
</label>
<div
class=
"flex rounded-lg bg-gray-100 dark:bg-dark-700 p-1 mt-2"
>
<button
type=
"button"
@
click=
"form.platform = 'anthropic'"
:class=
"[
'flex-1 flex items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
form.platform === 'anthropic'
? 'bg-white dark:bg-dark-600 text-orange-600 dark:text-orange-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
]"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"
/>
</svg>
Anthropic
</button>
<button
type=
"button"
@
click=
"form.platform = 'openai'"
:class=
"[
'flex-1 flex items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
form.platform === 'openai'
? 'bg-white dark:bg-dark-600 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
]"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"
/>
</svg>
OpenAI
</button>
</div>
</div>
<!-- Account Type Selection (Anthropic) -->
<div
v-if=
"form.platform === 'anthropic'"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.accountType
'
)
}}
</label>
<div
class=
"grid grid-cols-2 gap-3 mt-2"
>
<label
<button
type=
"button"
@
click=
"accountCategory = 'oauth-based'"
:class=
"[
'
relative flex cursor-pointer
rounded-lg border-2 p-
4
transition-all',
'
flex items-center gap-3
rounded-lg border-2 p-
3
transition-all
text-left
',
accountCategory === 'oauth-based'
? 'border-
primary
-500 bg-
primary
-50 dark:bg-
primary
-900/20'
: 'border-gray-200 dark:border-dark-600 hover:border-
primary-3
00'
? 'border-
orange
-500 bg-
orange
-50 dark:bg-
orange
-900/20'
: 'border-gray-200 dark:border-dark-600 hover:border-
orange-300 dark:hover:border-orange-7
00'
]"
>
<input
v-model=
"accountCategory"
type=
"radio"
value=
"oauth-based"
class=
"sr-only"
/>
<div
class=
"flex items-center gap-3"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-orange-500 to-orange-600"
>
<svg
class=
"w-5 h-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
accountCategory === 'oauth-based'
? 'bg-orange-500 text-white'
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400'
]"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"
/>
</svg>
</div>
<div>
<span
class=
"block text-sm font-
semibold
text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.claudeCode
'
)
}}
</span>
<span
class=
"block text-sm font-
medium
text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.claudeCode
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.oauthSetupToken
'
)
}}
</span>
</div>
</div>
<div
v-if=
"accountCategory === 'oauth-based'"
class=
"absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-primary-500"
>
<svg
class=
"w-3 h-3 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"3"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4.5 12.75l6 6 9-13.5"
/>
</svg>
</div>
</label>
</button>
<label
<button
type=
"button"
@
click=
"accountCategory = 'apikey'"
:class=
"[
'
relative flex cursor-pointer
rounded-lg border-2 p-
4
transition-all',
'
flex items-center gap-3
rounded-lg border-2 p-
3
transition-all
text-left
',
accountCategory === 'apikey'
? 'border-p
rimary
-500 bg-p
rimary
-50 dark:bg-p
rimary
-900/20'
: 'border-gray-200 dark:border-dark-600 hover:border-p
rimary-3
00'
? 'border-p
urple
-500 bg-p
urple
-50 dark:bg-p
urple
-900/20'
: 'border-gray-200 dark:border-dark-600 hover:border-p
urple-300 dark:hover:border-purple-7
00'
]"
>
<input
v-model=
"accountCategory"
type=
"radio"
value=
"apikey"
class=
"sr-only"
/>
<div
class=
"flex items-center gap-3"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-purple-500 to-purple-600"
>
<svg
class=
"w-5 h-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
accountCategory === 'apikey'
? 'bg-purple-500 text-white'
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400'
]"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div>
<div>
<span
class=
"block text-sm font-
semibold
text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.claudeConsole
'
)
}}
</span>
<span
class=
"block text-sm font-
medium
text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.claudeConsole
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.apiKey
'
)
}}
</span>
</div>
</button>
</div>
</div>
<div
v-if=
"accountCategory === 'apikey'"
class=
"absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-primary-500"
<!-- Account Type Selection (OpenAI) -->
<div
v-if=
"form.platform === 'openai'"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.accountType
'
)
}}
</label>
<div
class=
"grid grid-cols-2 gap-3 mt-2"
>
<button
type=
"button"
@
click=
"accountCategory = 'oauth-based'"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 transition-all text-left',
accountCategory === 'oauth-based'
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 dark:border-dark-600 hover:border-green-300 dark:hover:border-green-700'
]"
>
<svg
class=
"w-3 h-3 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"3"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4.5 12.75l6 6 9-13.5"
/>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
accountCategory === 'oauth-based'
? 'bg-green-500 text-white'
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400'
]"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div>
</label>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
OAuth
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
ChatGPT Plus
</span>
</div>
</button>
<button
type=
"button"
@
click=
"accountCategory = 'apikey'"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 transition-all text-left',
accountCategory === 'apikey'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 dark:border-dark-600 hover:border-purple-300 dark:hover:border-purple-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
accountCategory === 'apikey'
? 'bg-purple-500 text-white'
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400'
]"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
API Key
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
Responses API
</span>
</div>
</button>
</div>
</div>
<!-- Add Method (only for OAuth-based type) -->
<div
v-if=
"isOAuthFlow"
>
<!-- Add Method (only for
Anthropic
OAuth-based type) -->
<div
v-if=
"
form.platform === 'anthropic' &&
isOAuthFlow"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.addMethod
'
)
}}
</label>
<div
class=
"flex gap-4 mt-2"
>
<label
class=
"flex cursor-pointer items-center"
>
...
...
@@ -155,7 +233,7 @@
v-model=
"apiKeyBaseUrl"
type=
"text"
class=
"input"
placeholder=
"https://api.anthropic.com"
:
placeholder=
"
form.platform === 'openai' ? 'https://api.openai.com' : '
https://api.anthropic.com
'
"
/>
<p
class=
"input-hint"
>
{{
t
(
'
admin.accounts.baseUrlHint
'
)
}}
</p>
</div>
...
...
@@ -166,7 +244,7 @@
type=
"password"
required
class=
"input font-mono"
:placeholder=
"
t('admin.accounts.apiKeyPlaceholder')
"
:placeholder=
"
form.platform === 'openai' ? 'sk-proj-...' : 'sk-ant-...'
"
/>
<p
class=
"input-hint"
>
{{
t
(
'
admin.accounts.apiKeyHint
'
)
}}
</p>
</div>
...
...
@@ -418,8 +496,8 @@
<
/div
>
<
/div
>
<!--
Intercept
Warmup
Requests
(
all
account
types
)
-->
<
div
class
=
"
border-t border-gray-200 dark:border-dark-600 pt-4
"
>
<!--
Intercept
Warmup
Requests
(
Anthropic
only
)
-->
<
div
v
-
if
=
"
form.platform === 'anthropic'
"
class
=
"
border-t border-gray-200 dark:border-dark-600 pt-4
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.interceptWarmupRequests
'
)
}}
<
/label
>
...
...
@@ -477,6 +555,7 @@
<
GroupSelector
v
-
model
=
"
form.group_ids
"
:
groups
=
"
groups
"
:
platform
=
"
form.platform
"
/>
<
div
class
=
"
flex justify-end gap-3 pt-4
"
>
...
...
@@ -510,14 +589,16 @@
<
div
v
-
else
class
=
"
space-y-5
"
>
<
OAuthAuthorizationFlow
ref
=
"
oauthFlowRef
"
:
add
-
method
=
"
addMethod
"
:
auth
-
url
=
"
oauth.authUrl.value
"
:
session
-
id
=
"
oauth.s
essionId
.value
"
:
loading
=
"
oa
uth
.l
oading
.value
"
:
error
=
"
oa
uth
.e
rror
.value
"
:
show
-
help
=
"
true
"
:
add
-
method
=
"
form.platform === 'openai' ? 'oauth' :
addMethod
"
:
auth
-
url
=
"
currentAuthUrl
"
:
session
-
id
=
"
currentS
essionId
"
:
loading
=
"
currentOA
uth
L
oading
"
:
error
=
"
currentOA
uth
E
rror
"
:
show
-
help
=
"
form.platform !== 'openai'
"
:
show
-
proxy
-
warning
=
"
!!form.proxy_id
"
:
allow
-
multiple
=
"
true
"
:
allow
-
multiple
=
"
form.platform !== 'openai'
"
:
show
-
cookie
-
option
=
"
form.platform !== 'openai'
"
:
platform
=
"
form.platform
"
@
generate
-
url
=
"
handleGenerateUrl
"
@
cookie
-
auth
=
"
handleCookieAuth
"
/>
...
...
@@ -538,7 +619,7 @@
@
click
=
"
handleExchangeCode
"
>
<
svg
v
-
if
=
"
oa
uth
.l
oading
.value
"
v
-
if
=
"
currentOA
uth
L
oading
"
class
=
"
animate-spin -ml-1 mr-2 h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
...
...
@@ -546,7 +627,7 @@
<
circle
class
=
"
opacity-25
"
cx
=
"
12
"
cy
=
"
12
"
r
=
"
10
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
4
"
><
/circle
>
<
path
class
=
"
opacity-75
"
fill
=
"
currentColor
"
d
=
"
M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z
"
><
/path
>
<
/svg
>
{{
oa
uth
.
l
oading
.
value
?
t
(
'
admin.accounts.oauth.verifying
'
)
:
t
(
'
admin.accounts.oauth.completeAuth
'
)
}}
{{
currentOA
uth
L
oading
?
t
(
'
admin.accounts.oauth.verifying
'
)
:
t
(
'
admin.accounts.oauth.completeAuth
'
)
}}
<
/button
>
<
/div
>
<
/div
>
...
...
@@ -559,6 +640,7 @@ import { useI18n } from 'vue-i18n'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
useAccountOAuth
,
type
AddMethod
,
type
AuthInputMethod
}
from
'
@/composables/useAccountOAuth
'
import
{
useOpenAIOAuth
}
from
'
@/composables/useOpenAIOAuth
'
import
type
{
Proxy
,
Group
,
AccountPlatform
,
AccountType
}
from
'
@/types
'
import
Modal
from
'
@/components/common/Modal.vue
'
import
ProxySelector
from
'
@/components/common/ProxySelector.vue
'
...
...
@@ -590,8 +672,26 @@ const emit = defineEmits<{
const
appStore
=
useAppStore
()
// OAuth composable
const
oauth
=
useAccountOAuth
()
// OAuth composables
const
oauth
=
useAccountOAuth
()
// For Anthropic OAuth
const
openaiOAuth
=
useOpenAIOAuth
()
// For OpenAI OAuth
// Computed: current OAuth state for template binding
const
currentAuthUrl
=
computed
(()
=>
{
return
form
.
platform
===
'
openai
'
?
openaiOAuth
.
authUrl
.
value
:
oauth
.
authUrl
.
value
}
)
const
currentSessionId
=
computed
(()
=>
{
return
form
.
platform
===
'
openai
'
?
openaiOAuth
.
sessionId
.
value
:
oauth
.
sessionId
.
value
}
)
const
currentOAuthLoading
=
computed
(()
=>
{
return
form
.
platform
===
'
openai
'
?
openaiOAuth
.
loading
.
value
:
oauth
.
loading
.
value
}
)
const
currentOAuthError
=
computed
(()
=>
{
return
form
.
platform
===
'
openai
'
?
openaiOAuth
.
error
.
value
:
oauth
.
error
.
value
}
)
// Refs
const
oauthFlowRef
=
ref
<
OAuthFlowExposed
|
null
>
(
null
)
...
...
@@ -617,8 +717,8 @@ const selectedErrorCodes = ref<number[]>([])
const
customErrorCodeInput
=
ref
<
number
|
null
>
(
null
)
const
interceptWarmupRequests
=
ref
(
false
)
// Common models for whitelist
const
common
Models
=
[
// Common models for whitelist
- Anthropic
const
anthropic
Models
=
[
{
value
:
'
claude-opus-4-5-20251101
'
,
label
:
'
Claude Opus 4.5
'
}
,
{
value
:
'
claude-sonnet-4-20250514
'
,
label
:
'
Claude Sonnet 4
'
}
,
{
value
:
'
claude-sonnet-4-5-20250929
'
,
label
:
'
Claude Sonnet 4.5
'
}
,
...
...
@@ -629,8 +729,24 @@ const commonModels = [
{
value
:
'
claude-3-haiku-20240307
'
,
label
:
'
Claude 3 Haiku
'
}
]
// Preset mappings for quick add
const
presetMappings
=
[
// Common models for whitelist - OpenAI
const
openaiModels
=
[
{
value
:
'
gpt-5.2-2025-12-11
'
,
label
:
'
GPT-5.2
'
}
,
{
value
:
'
gpt-5.2-codex
'
,
label
:
'
GPT-5.2 Codex
'
}
,
{
value
:
'
gpt-5.1-codex-max
'
,
label
:
'
GPT-5.1 Codex Max
'
}
,
{
value
:
'
gpt-5.1-codex
'
,
label
:
'
GPT-5.1 Codex
'
}
,
{
value
:
'
gpt-5.1-2025-11-13
'
,
label
:
'
GPT-5.1
'
}
,
{
value
:
'
gpt-5.1-codex-mini
'
,
label
:
'
GPT-5.1 Codex Mini
'
}
,
{
value
:
'
gpt-5-2025-08-07
'
,
label
:
'
GPT-5
'
}
]
// Computed: current models based on platform
const
commonModels
=
computed
(()
=>
{
return
form
.
platform
===
'
openai
'
?
openaiModels
:
anthropicModels
}
)
// Preset mappings for quick add - Anthropic
const
anthropicPresetMappings
=
[
{
label
:
'
Sonnet 4
'
,
from
:
'
claude-sonnet-4-20250514
'
,
to
:
'
claude-sonnet-4-20250514
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
}
,
{
label
:
'
Sonnet 4.5
'
,
from
:
'
claude-sonnet-4-5-20250929
'
,
to
:
'
claude-sonnet-4-5-20250929
'
,
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
}
,
{
label
:
'
Opus 4.5
'
,
from
:
'
claude-opus-4-5-20251101
'
,
to
:
'
claude-opus-4-5-20251101
'
,
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
}
,
...
...
@@ -639,6 +755,21 @@ const presetMappings = [
{
label
:
'
Opus->Sonnet
'
,
from
:
'
claude-opus-4-5-20251101
'
,
to
:
'
claude-sonnet-4-5-20250929
'
,
color
:
'
bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400
'
}
]
// Preset mappings for quick add - OpenAI
const
openaiPresetMappings
=
[
{
label
:
'
GPT-5.2
'
,
from
:
'
gpt-5.2-2025-12-11
'
,
to
:
'
gpt-5.2-2025-12-11
'
,
color
:
'
bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400
'
}
,
{
label
:
'
GPT-5.2 Codex
'
,
from
:
'
gpt-5.2-codex
'
,
to
:
'
gpt-5.2-codex
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
}
,
{
label
:
'
GPT-5.1 Codex
'
,
from
:
'
gpt-5.1-codex
'
,
to
:
'
gpt-5.1-codex
'
,
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
}
,
{
label
:
'
Codex Max
'
,
from
:
'
gpt-5.1-codex-max
'
,
to
:
'
gpt-5.1-codex-max
'
,
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
}
,
{
label
:
'
Codex Mini
'
,
from
:
'
gpt-5.1-codex-mini
'
,
to
:
'
gpt-5.1-codex-mini
'
,
color
:
'
bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400
'
}
,
{
label
:
'
Max->Codex
'
,
from
:
'
gpt-5.1-codex-max
'
,
to
:
'
gpt-5.1-codex
'
,
color
:
'
bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400
'
}
]
// Computed: current preset mappings based on platform
const
presetMappings
=
computed
(()
=>
{
return
form
.
platform
===
'
openai
'
?
openaiPresetMappings
:
anthropicPresetMappings
}
)
// Common HTTP error codes for quick selection
const
commonErrorCodes
=
[
{
value
:
401
,
label
:
'
Unauthorized
'
}
,
...
...
@@ -670,6 +801,9 @@ const isManualInputMethod = computed(() => {
const
canExchangeCode
=
computed
(()
=>
{
const
authCode
=
oauthFlowRef
.
value
?.
authCode
||
''
if
(
form
.
platform
===
'
openai
'
)
{
return
authCode
.
trim
()
&&
openaiOAuth
.
sessionId
.
value
&&
!
openaiOAuth
.
loading
.
value
}
return
authCode
.
trim
()
&&
oauth
.
sessionId
.
value
&&
!
oauth
.
loading
.
value
}
)
...
...
@@ -689,6 +823,20 @@ watch([accountCategory, addMethod], ([category, method]) => {
}
}
,
{
immediate
:
true
}
)
// Reset platform-specific settings when platform changes
watch
(()
=>
form
.
platform
,
(
newPlatform
)
=>
{
// Reset base URL based on platform
apiKeyBaseUrl
.
value
=
newPlatform
===
'
openai
'
?
'
https://api.openai.com
'
:
'
https://api.anthropic.com
'
// Clear model-related settings
allowedModels
.
value
=
[]
modelMappings
.
value
=
[]
// Reset OAuth states
oauth
.
resetState
()
openaiOAuth
.
resetState
()
}
)
// Model mapping helpers
const
addModelMapping
=
()
=>
{
modelMappings
.
value
.
push
({
from
:
''
,
to
:
''
}
)
...
...
@@ -786,6 +934,7 @@ const resetForm = () => {
customErrorCodeInput
.
value
=
null
interceptWarmupRequests
.
value
=
false
oauth
.
resetState
()
openaiOAuth
.
resetState
()
oauthFlowRef
.
value
?.
reset
()
}
...
...
@@ -810,9 +959,14 @@ const handleSubmit = async () => {
return
}
// Determine default base URL based on platform
const
defaultBaseUrl
=
form
.
platform
===
'
openai
'
?
'
https://api.openai.com
'
:
'
https://api.anthropic.com
'
// Build credentials with optional model mapping
const
credentials
:
Record
<
string
,
unknown
>
=
{
base_url
:
apiKeyBaseUrl
.
value
.
trim
()
||
'
https://api.anthropic.com
'
,
base_url
:
apiKeyBaseUrl
.
value
.
trim
()
||
defaultBaseUrl
,
api_key
:
apiKeyValue
.
value
.
trim
()
}
...
...
@@ -837,7 +991,10 @@ const handleSubmit = async () => {
submitting
.
value
=
true
try
{
await
adminAPI
.
accounts
.
create
(
form
)
await
adminAPI
.
accounts
.
create
({
...
form
,
group_ids
:
form
.
group_ids
}
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.accountCreated
'
))
emit
(
'
created
'
)
handleClose
()
...
...
@@ -851,15 +1008,72 @@ const handleSubmit = async () => {
const
goBackToBasicInfo
=
()
=>
{
step
.
value
=
1
oauth
.
resetState
()
openaiOAuth
.
resetState
()
oauthFlowRef
.
value
?.
reset
()
}
const
handleGenerateUrl
=
async
()
=>
{
if
(
form
.
platform
===
'
openai
'
)
{
await
openaiOAuth
.
generateAuthUrl
(
form
.
proxy_id
)
}
else
{
await
oauth
.
generateAuthUrl
(
addMethod
.
value
,
form
.
proxy_id
)
}
}
const
handleExchangeCode
=
async
()
=>
{
const
authCode
=
oauthFlowRef
.
value
?.
authCode
||
''
// For OpenAI
if
(
form
.
platform
===
'
openai
'
)
{
if
(
!
authCode
.
trim
()
||
!
openaiOAuth
.
sessionId
.
value
)
return
openaiOAuth
.
loading
.
value
=
true
openaiOAuth
.
error
.
value
=
''
try
{
const
tokenInfo
=
await
openaiOAuth
.
exchangeAuthCode
(
authCode
.
trim
(),
openaiOAuth
.
sessionId
.
value
,
form
.
proxy_id
)
if
(
!
tokenInfo
)
{
return
// Error already handled by composable
}
const
credentials
=
openaiOAuth
.
buildCredentials
(
tokenInfo
)
const
extra
=
openaiOAuth
.
buildExtraInfo
(
tokenInfo
)
// Merge interceptWarmupRequests into credentials
if
(
interceptWarmupRequests
.
value
)
{
credentials
.
intercept_warmup_requests
=
true
}
await
adminAPI
.
accounts
.
create
({
name
:
form
.
name
,
platform
:
'
openai
'
,
type
:
'
oauth
'
,
credentials
,
extra
,
proxy_id
:
form
.
proxy_id
,
concurrency
:
form
.
concurrency
,
priority
:
form
.
priority
,
group_ids
:
form
.
group_ids
}
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.accountCreated
'
))
emit
(
'
created
'
)
handleClose
()
}
catch
(
error
:
any
)
{
openaiOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
appStore
.
showError
(
openaiOAuth
.
error
.
value
)
}
finally
{
openaiOAuth
.
loading
.
value
=
false
}
return
}
// For Anthropic
if
(
!
authCode
.
trim
()
||
!
oauth
.
sessionId
.
value
)
return
oauth
.
loading
.
value
=
true
...
...
@@ -893,7 +1107,8 @@ const handleExchangeCode = async () => {
extra
,
proxy_id
:
form
.
proxy_id
,
concurrency
:
form
.
concurrency
,
priority
:
form
.
priority
priority
:
form
.
priority
,
group_ids
:
form
.
group_ids
}
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.accountCreated
'
))
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
6c469b42
...
...
@@ -24,7 +24,7 @@
v-model=
"editBaseUrl"
type=
"text"
class=
"input"
placeholder=
"https://api.anthropic.com"
:
placeholder=
"
account.platform === 'openai' ? 'https://api.openai.com' : '
https://api.anthropic.com
'
"
/>
<p
class=
"input-hint"
>
{{
t
(
'
admin.accounts.baseUrlHint
'
)
}}
</p>
</div>
...
...
@@ -34,7 +34,7 @@
v-model=
"editApiKey"
type=
"password"
class=
"input font-mono"
:placeholder=
"
t('admin.accounts.leaveEmptyToKeep')
"
:placeholder=
"
account.platform === 'openai' ? 'sk-proj-...' : 'sk-ant-...'
"
/>
<p
class=
"input-hint"
>
{{
t
(
'
admin.accounts.leaveEmptyToKeep
'
)
}}
</p>
</div>
...
...
@@ -286,8 +286,8 @@
<
/div
>
<
/div
>
<!--
Intercept
Warmup
Requests
(
all
account
types
)
-->
<
div
class
=
"
border-t border-gray-200 dark:border-dark-600 pt-4
"
>
<!--
Intercept
Warmup
Requests
(
Anthropic
only
)
-->
<
div
v
-
if
=
"
account?.platform === 'anthropic'
"
class
=
"
border-t border-gray-200 dark:border-dark-600 pt-4
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.interceptWarmupRequests
'
)
}}
<
/label
>
...
...
@@ -352,6 +352,7 @@
<
GroupSelector
v
-
model
=
"
form.group_ids
"
:
groups
=
"
groups
"
:
platform
=
"
account?.platform
"
/>
<
div
class
=
"
flex justify-end gap-3 pt-4
"
>
...
...
@@ -428,8 +429,8 @@ const selectedErrorCodes = ref<number[]>([])
const
customErrorCodeInput
=
ref
<
number
|
null
>
(
null
)
const
interceptWarmupRequests
=
ref
(
false
)
// Common models for whitelist
const
common
Models
=
[
// Common models for whitelist
- Anthropic
const
anthropic
Models
=
[
{
value
:
'
claude-opus-4-5-20251101
'
,
label
:
'
Claude Opus 4.5
'
}
,
{
value
:
'
claude-sonnet-4-20250514
'
,
label
:
'
Claude Sonnet 4
'
}
,
{
value
:
'
claude-sonnet-4-5-20250929
'
,
label
:
'
Claude Sonnet 4.5
'
}
,
...
...
@@ -440,8 +441,24 @@ const commonModels = [
{
value
:
'
claude-3-haiku-20240307
'
,
label
:
'
Claude 3 Haiku
'
}
]
// Preset mappings for quick add
const
presetMappings
=
[
// Common models for whitelist - OpenAI
const
openaiModels
=
[
{
value
:
'
gpt-5.2-2025-12-11
'
,
label
:
'
GPT-5.2
'
}
,
{
value
:
'
gpt-5.2-codex
'
,
label
:
'
GPT-5.2 Codex
'
}
,
{
value
:
'
gpt-5.1-codex-max
'
,
label
:
'
GPT-5.1 Codex Max
'
}
,
{
value
:
'
gpt-5.1-codex
'
,
label
:
'
GPT-5.1 Codex
'
}
,
{
value
:
'
gpt-5.1-2025-11-13
'
,
label
:
'
GPT-5.1
'
}
,
{
value
:
'
gpt-5.1-codex-mini
'
,
label
:
'
GPT-5.1 Codex Mini
'
}
,
{
value
:
'
gpt-5-2025-08-07
'
,
label
:
'
GPT-5
'
}
]
// Computed: current models based on platform
const
commonModels
=
computed
(()
=>
{
return
props
.
account
?.
platform
===
'
openai
'
?
openaiModels
:
anthropicModels
}
)
// Preset mappings for quick add - Anthropic
const
anthropicPresetMappings
=
[
{
label
:
'
Sonnet 4
'
,
from
:
'
claude-sonnet-4-20250514
'
,
to
:
'
claude-sonnet-4-20250514
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
}
,
{
label
:
'
Sonnet 4.5
'
,
from
:
'
claude-sonnet-4-5-20250929
'
,
to
:
'
claude-sonnet-4-5-20250929
'
,
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
}
,
{
label
:
'
Opus 4.5
'
,
from
:
'
claude-opus-4-5-20251101
'
,
to
:
'
claude-opus-4-5-20251101
'
,
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
}
,
...
...
@@ -450,6 +467,26 @@ const presetMappings = [
{
label
:
'
Opus->Sonnet
'
,
from
:
'
claude-opus-4-5-20251101
'
,
to
:
'
claude-sonnet-4-5-20250929
'
,
color
:
'
bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400
'
}
]
// Preset mappings for quick add - OpenAI
const
openaiPresetMappings
=
[
{
label
:
'
GPT-5.2
'
,
from
:
'
gpt-5.2-2025-12-11
'
,
to
:
'
gpt-5.2-2025-12-11
'
,
color
:
'
bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400
'
}
,
{
label
:
'
GPT-5.2 Codex
'
,
from
:
'
gpt-5.2-codex
'
,
to
:
'
gpt-5.2-codex
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
}
,
{
label
:
'
GPT-5.1 Codex
'
,
from
:
'
gpt-5.1-codex
'
,
to
:
'
gpt-5.1-codex
'
,
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
}
,
{
label
:
'
Codex Max
'
,
from
:
'
gpt-5.1-codex-max
'
,
to
:
'
gpt-5.1-codex-max
'
,
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
}
,
{
label
:
'
Codex Mini
'
,
from
:
'
gpt-5.1-codex-mini
'
,
to
:
'
gpt-5.1-codex-mini
'
,
color
:
'
bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400
'
}
,
{
label
:
'
Max->Codex
'
,
from
:
'
gpt-5.1-codex-max
'
,
to
:
'
gpt-5.1-codex
'
,
color
:
'
bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400
'
}
]
// Computed: current preset mappings based on platform
const
presetMappings
=
computed
(()
=>
{
return
props
.
account
?.
platform
===
'
openai
'
?
openaiPresetMappings
:
anthropicPresetMappings
}
)
// Computed: default base URL based on platform
const
defaultBaseUrl
=
computed
(()
=>
{
return
props
.
account
?.
platform
===
'
openai
'
?
'
https://api.openai.com
'
:
'
https://api.anthropic.com
'
}
)
// Common HTTP error codes for quick selection
const
commonErrorCodes
=
[
{
value
:
401
,
label
:
'
Unauthorized
'
}
,
...
...
@@ -492,7 +529,8 @@ watch(() => props.account, (newAccount) => {
// Initialize API Key fields for apikey type
if
(
newAccount
.
type
===
'
apikey
'
&&
newAccount
.
credentials
)
{
const
credentials
=
newAccount
.
credentials
as
Record
<
string
,
unknown
>
editBaseUrl
.
value
=
credentials
.
base_url
as
string
||
'
https://api.anthropic.com
'
const
platformDefaultUrl
=
newAccount
.
platform
===
'
openai
'
?
'
https://api.openai.com
'
:
'
https://api.anthropic.com
'
editBaseUrl
.
value
=
credentials
.
base_url
as
string
||
platformDefaultUrl
// Load model mappings and detect mode
const
existingMappings
=
credentials
.
model_mapping
as
Record
<
string
,
string
>
|
undefined
...
...
@@ -529,7 +567,8 @@ watch(() => props.account, (newAccount) => {
selectedErrorCodes
.
value
=
[]
}
}
else
{
editBaseUrl
.
value
=
'
https://api.anthropic.com
'
const
platformDefaultUrl
=
newAccount
.
platform
===
'
openai
'
?
'
https://api.openai.com
'
:
'
https://api.anthropic.com
'
editBaseUrl
.
value
=
platformDefaultUrl
modelRestrictionMode
.
value
=
'
whitelist
'
modelMappings
.
value
=
[]
allowedModels
.
value
=
[]
...
...
@@ -628,7 +667,7 @@ const handleSubmit = async () => {
// For apikey type, handle credentials update
if
(
props
.
account
.
type
===
'
apikey
'
)
{
const
currentCredentials
=
props
.
account
.
credentials
as
Record
<
string
,
unknown
>
||
{
}
const
newBaseUrl
=
editBaseUrl
.
value
.
trim
()
||
'
https://api.anthropic.com
'
const
newBaseUrl
=
editBaseUrl
.
value
.
trim
()
||
defaultBaseUrl
.
value
const
modelMapping
=
buildModelMappingObject
()
// Always update credentials for apikey type to handle model mapping changes
...
...
frontend/src/components/account/OAuthAuthorizationFlow.vue
View file @
6c469b42
...
...
@@ -7,10 +7,10 @@
</svg>
</div>
<div
class=
"flex-1"
>
<h4
class=
"mb-3 font-semibold text-blue-900 dark:text-blue-200"
>
{{
t
(
'
admin.accounts.
oauth
.t
itle
'
)
}}
</h4>
<h4
class=
"mb-3 font-semibold text-blue-900 dark:text-blue-200"
>
{{
oauth
T
itle
}}
</h4>
<!-- Auth Method Selection -->
<div
class=
"mb-4"
>
<div
v-if=
"showCookieOption"
class=
"mb-4"
>
<label
class=
"mb-2 block text-sm font-medium text-blue-800 dark:text-blue-300"
>
{{
methodLabel
}}
</label>
...
...
@@ -132,7 +132,7 @@
<!--
Manual
Authorization
Flow
-->
<
div
v
-
else
class
=
"
space-y-4
"
>
<
p
class
=
"
mb-4 text-sm text-blue-800 dark:text-blue-300
"
>
{{
t
(
'
admin.accounts.
oauth
.f
ollowSteps
'
)
}}
{{
oauth
F
ollowSteps
}}
<
/p
>
<!--
Step
1
:
Generate
Auth
URL
-->
...
...
@@ -143,7 +143,7 @@
<
/div
>
<
div
class
=
"
flex-1
"
>
<
p
class
=
"
mb-2 font-medium text-blue-900 dark:text-blue-200
"
>
{{
t
(
'
admin.accounts.
oauth
.s
tep1GenerateUrl
'
)
}}
{{
oauth
S
tep1GenerateUrl
}}
<
/p
>
<
button
v
-
if
=
"
!authUrl
"
...
...
@@ -159,7 +159,7 @@
<
svg
v
-
else
class
=
"
w-4 h-4 mr-2
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244
"
/>
<
/svg
>
{{
loading
?
t
(
'
admin.accounts.oauth.generating
'
)
:
t
(
'
admin.accounts.
oauth
.g
enerateAuthUrl
'
)
}}
{{
loading
?
t
(
'
admin.accounts.oauth.generating
'
)
:
oauth
G
enerateAuthUrl
}}
<
/button
>
<
div
v
-
else
class
=
"
space-y-3
"
>
<
div
class
=
"
flex items-center gap-2
"
>
...
...
@@ -206,12 +206,18 @@
<
/div
>
<
div
class
=
"
flex-1
"
>
<
p
class
=
"
mb-2 font-medium text-blue-900 dark:text-blue-200
"
>
{{
t
(
'
admin.accounts.
oauth
.s
tep2OpenUrl
'
)
}}
{{
oauth
S
tep2OpenUrl
}}
<
/p
>
<
p
class
=
"
text-sm text-blue-700 dark:text-blue-300
"
>
{{
t
(
'
admin.accounts.oauth.openUrlDesc
'
)
}}
{{
oauthOpenUrlDesc
}}
<
/p
>
<!--
OpenAI
Important
Notice
-->
<
div
v
-
if
=
"
isOpenAI
"
class
=
"
mt-2 rounded border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/30 p-3
"
>
<
p
class
=
"
text-xs text-amber-800 dark:text-amber-300
"
v
-
html
=
"
oauthImportantNotice
"
>
<
/p
>
<
div
v
-
if
=
"
showProxyWarning
"
class
=
"
mt-2 rounded border border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/30 p-3
"
>
<
/div
>
<!--
Proxy
Warning
(
for
non
-
OpenAI
)
-->
<
div
v
-
else
-
if
=
"
showProxyWarning
"
class
=
"
mt-2 rounded border border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/30 p-3
"
>
<
p
class
=
"
text-xs text-yellow-800 dark:text-yellow-300
"
v
-
html
=
"
t('admin.accounts.oauth.proxyWarning')
"
>
<
/p
>
<
/div
>
...
...
@@ -227,28 +233,28 @@
<
/div
>
<
div
class
=
"
flex-1
"
>
<
p
class
=
"
mb-2 font-medium text-blue-900 dark:text-blue-200
"
>
{{
t
(
'
admin.accounts.
oauth
.s
tep3EnterCode
'
)
}}
{{
oauth
S
tep3EnterCode
}}
<
/p
>
<
p
class
=
"
mb-3 text-sm text-blue-700 dark:text-blue-300
"
v
-
html
=
"
t('admin.accounts.
oauth
.a
uthCodeDesc
')
"
>
<
p
class
=
"
mb-3 text-sm text-blue-700 dark:text-blue-300
"
v
-
html
=
"
oauth
A
uthCodeDesc
"
>
<
/p
>
<
div
>
<
label
class
=
"
input-label
"
>
<
svg
class
=
"
w-4 h-4 inline mr-1 text-blue-500
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.
oauth
.a
uthCode
'
)
}}
{{
oauth
A
uthCode
}}
<
/label
>
<
textarea
v
-
model
=
"
authCodeInput
"
rows
=
"
3
"
class
=
"
input w-full font-mono text-sm resize-none
"
:
placeholder
=
"
t('admin.accounts.
oauth
.a
uthCodePlaceholder
')
"
:
placeholder
=
"
oauth
A
uthCodePlaceholder
"
><
/textarea
>
<
p
class
=
"
mt-2 text-xs text-gray-500 dark:text-gray-400
"
>
<
svg
class
=
"
w-3 h-3 inline mr-1
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.
oauth
.a
uthCodeHint
'
)
}}
{{
oauth
A
uthCodeHint
}}
<
/p
>
<
/div
>
...
...
@@ -286,6 +292,8 @@ interface Props {
showProxyWarning
?:
boolean
allowMultiple
?:
boolean
methodLabel
?:
string
showCookieOption
?:
boolean
// Whether to show cookie auto-auth option
platform
?:
'
anthropic
'
|
'
openai
'
// Platform type for different UI/text
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
...
...
@@ -296,7 +304,9 @@ const props = withDefaults(defineProps<Props>(), {
showHelp
:
true
,
showProxyWarning
:
true
,
allowMultiple
:
false
,
methodLabel
:
'
Authorization Method
'
methodLabel
:
'
Authorization Method
'
,
showCookieOption
:
true
,
platform
:
'
anthropic
'
}
)
const
emit
=
defineEmits
<
{
...
...
@@ -308,8 +318,35 @@ const emit = defineEmits<{
const
{
t
}
=
useI18n
()
// Platform-specific translation helpers
const
isOpenAI
=
computed
(()
=>
props
.
platform
===
'
openai
'
)
// Get translation key based on platform
const
getOAuthKey
=
(
key
:
string
)
=>
{
if
(
isOpenAI
.
value
)
{
// Try OpenAI-specific key first
const
openaiKey
=
`admin.accounts.oauth.openai.${key
}
`
return
openaiKey
}
return
`admin.accounts.oauth.${key
}
`
}
// Computed translations for current platform
const
oauthTitle
=
computed
(()
=>
t
(
getOAuthKey
(
'
title
'
)))
const
oauthFollowSteps
=
computed
(()
=>
t
(
getOAuthKey
(
'
followSteps
'
)))
const
oauthStep1GenerateUrl
=
computed
(()
=>
t
(
getOAuthKey
(
'
step1GenerateUrl
'
)))
const
oauthGenerateAuthUrl
=
computed
(()
=>
t
(
getOAuthKey
(
'
generateAuthUrl
'
)))
const
oauthStep2OpenUrl
=
computed
(()
=>
t
(
getOAuthKey
(
'
step2OpenUrl
'
)))
const
oauthOpenUrlDesc
=
computed
(()
=>
t
(
getOAuthKey
(
'
openUrlDesc
'
)))
const
oauthStep3EnterCode
=
computed
(()
=>
t
(
getOAuthKey
(
'
step3EnterCode
'
)))
const
oauthAuthCodeDesc
=
computed
(()
=>
t
(
getOAuthKey
(
'
authCodeDesc
'
)))
const
oauthAuthCode
=
computed
(()
=>
t
(
getOAuthKey
(
'
authCode
'
)))
const
oauthAuthCodePlaceholder
=
computed
(()
=>
t
(
getOAuthKey
(
'
authCodePlaceholder
'
)))
const
oauthAuthCodeHint
=
computed
(()
=>
t
(
getOAuthKey
(
'
authCodeHint
'
)))
const
oauthImportantNotice
=
computed
(()
=>
isOpenAI
.
value
?
t
(
'
admin.accounts.oauth.openai.importantNotice
'
)
:
''
)
// Local state
const
inputMethod
=
ref
<
AuthInputMethod
>
(
'
manual
'
)
const
inputMethod
=
ref
<
AuthInputMethod
>
(
props
.
showCookieOption
?
'
manual
'
:
'
manual
'
)
const
authCodeInput
=
ref
(
''
)
const
sessionKeyInput
=
ref
(
''
)
const
showHelpDialog
=
ref
(
false
)
...
...
@@ -327,6 +364,32 @@ watch(inputMethod, (newVal) => {
emit
(
'
update:inputMethod
'
,
newVal
)
}
)
// Auto-extract code from OpenAI callback URL
// e.g., http://localhost:1455/auth/callback?code=ac_xxx...&scope=...&state=...
watch
(
authCodeInput
,
(
newVal
)
=>
{
if
(
!
isOpenAI
.
value
)
return
const
trimmed
=
newVal
.
trim
()
// Check if it looks like a URL with code parameter
if
(
trimmed
.
includes
(
'
?
'
)
&&
trimmed
.
includes
(
'
code=
'
))
{
try
{
// Try to parse as URL
const
url
=
new
URL
(
trimmed
)
const
code
=
url
.
searchParams
.
get
(
'
code
'
)
if
(
code
&&
code
!==
trimmed
)
{
// Replace the input with just the code
authCodeInput
.
value
=
code
}
}
catch
{
// If URL parsing fails, try regex extraction
const
match
=
trimmed
.
match
(
/
[
?&
]
code=
([^
&
]
+
)
/
)
if
(
match
&&
match
[
1
]
&&
match
[
1
]
!==
trimmed
)
{
authCodeInput
.
value
=
match
[
1
]
}
}
}
}
)
// Methods
const
handleGenerateUrl
=
()
=>
{
emit
(
'
generate-url
'
)
...
...
frontend/src/components/account/ReAuthAccountModal.vue
View file @
6c469b42
...
...
@@ -9,20 +9,25 @@
<!-- Account Info -->
<div
class=
"rounded-lg border border-gray-200 dark:border-dark-600 bg-gray-50 dark:bg-dark-700 p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-orange-500 to-orange-600"
>
<div
:class=
"[
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
isOpenAI ? 'from-green-500 to-green-600' : 'from-orange-500 to-orange-600'
]"
>
<svg
class=
"w-5 h-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
</div>
<div>
<span
class=
"block font-semibold text-gray-900 dark:text-white"
>
{{
account
.
name
}}
</span>
<span
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.claudeCodeAccount
'
)
}}
</span>
<span
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
isOpenAI
?
t
(
'
admin.accounts.openaiAccount
'
)
:
t
(
'
admin.accounts.claudeCodeAccount
'
)
}}
</span>
</div>
</div>
</div>
<!-- Add Method Selection -->
<div>
<!-- Add Method Selection
(Claude only)
-->
<div
v-if=
"!isOpenAI"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.oauth.authMethod
'
)
}}
</label>
<div
class=
"flex gap-4 mt-2"
>
<label
class=
"flex cursor-pointer items-center"
>
...
...
@@ -50,14 +55,16 @@
<OAuthAuthorizationFlow
ref=
"oauthFlowRef"
:add-method=
"addMethod"
:auth-url=
"oauth.authUrl.value"
:session-id=
"oauth.sessionId.value"
:loading=
"oauth.loading.value"
:error=
"oauth.error.value"
:show-help=
"false"
:show-proxy-warning=
"false"
:auth-url=
"currentAuthUrl"
:session-id=
"currentSessionId"
:loading=
"currentLoading"
:error=
"currentError"
:show-help=
"!isOpenAI"
:show-proxy-warning=
"!isOpenAI"
:show-cookie-option=
"!isOpenAI"
:allow-multiple=
"false"
:method-label=
"t('admin.accounts.inputMethod')"
:platform=
"isOpenAI ? 'openai' : 'anthropic'"
@
generate-url=
"handleGenerateUrl"
@
cookie-auth=
"handleCookieAuth"
/>
...
...
@@ -78,7 +85,7 @@
@
click=
"handleExchangeCode"
>
<svg
v-if=
"
oauth.loading.value
"
v-if=
"
currentLoading
"
class=
"animate-spin -ml-1 mr-2 h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
...
...
@@ -86,7 +93,7 @@
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{
oauth
.
loading
.
value
?
t
(
'
admin.accounts.oauth.verifying
'
)
:
t
(
'
admin.accounts.oauth.completeAuth
'
)
}}
{{
currentLoading
?
t
(
'
admin.accounts.oauth.verifying
'
)
:
t
(
'
admin.accounts.oauth.completeAuth
'
)
}}
</button>
</div>
</div>
...
...
@@ -99,6 +106,7 @@ import { useI18n } from 'vue-i18n'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
useAccountOAuth
,
type
AddMethod
,
type
AuthInputMethod
}
from
'
@/composables/useAccountOAuth
'
import
{
useOpenAIOAuth
}
from
'
@/composables/useOpenAIOAuth
'
import
type
{
Account
}
from
'
@/types
'
import
Modal
from
'
@/components/common/Modal.vue
'
import
OAuthAuthorizationFlow
from
'
./OAuthAuthorizationFlow.vue
'
...
...
@@ -126,8 +134,9 @@ const emit = defineEmits<{
const
appStore
=
useAppStore
()
const
{
t
}
=
useI18n
()
// OAuth composable
const
oauth
=
useAccountOAuth
()
// OAuth composables - use both Claude and OpenAI
const
claudeOAuth
=
useAccountOAuth
()
const
openaiOAuth
=
useOpenAIOAuth
()
// Refs
const
oauthFlowRef
=
ref
<
OAuthFlowExposed
|
null
>
(
null
)
...
...
@@ -135,21 +144,33 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
// State
const
addMethod
=
ref
<
AddMethod
>
(
'
oauth
'
)
// Computed - check if this is an OpenAI account
const
isOpenAI
=
computed
(()
=>
props
.
account
?.
platform
===
'
openai
'
)
// Computed - current OAuth state based on platform
const
currentAuthUrl
=
computed
(()
=>
isOpenAI
.
value
?
openaiOAuth
.
authUrl
.
value
:
claudeOAuth
.
authUrl
.
value
)
const
currentSessionId
=
computed
(()
=>
isOpenAI
.
value
?
openaiOAuth
.
sessionId
.
value
:
claudeOAuth
.
sessionId
.
value
)
const
currentLoading
=
computed
(()
=>
isOpenAI
.
value
?
openaiOAuth
.
loading
.
value
:
claudeOAuth
.
loading
.
value
)
const
currentError
=
computed
(()
=>
isOpenAI
.
value
?
openaiOAuth
.
error
.
value
:
claudeOAuth
.
error
.
value
)
// Computed
const
isManualInputMethod
=
computed
(()
=>
{
return
oauthFlowRef
.
value
?.
inputMethod
===
'
manual
'
// OpenAI always uses manual input (no cookie auth option)
return
isOpenAI
.
value
||
oauthFlowRef
.
value
?.
inputMethod
===
'
manual
'
})
const
canExchangeCode
=
computed
(()
=>
{
const
authCode
=
oauthFlowRef
.
value
?.
authCode
||
''
return
authCode
.
trim
()
&&
oauth
.
sessionId
.
value
&&
!
oauth
.
loading
.
value
const
sessionId
=
isOpenAI
.
value
?
openaiOAuth
.
sessionId
.
value
:
claudeOAuth
.
sessionId
.
value
const
loading
=
isOpenAI
.
value
?
openaiOAuth
.
loading
.
value
:
claudeOAuth
.
loading
.
value
return
authCode
.
trim
()
&&
sessionId
&&
!
loading
})
// Watchers
watch
(()
=>
props
.
show
,
(
newVal
)
=>
{
if
(
newVal
&&
props
.
account
)
{
// Initialize addMethod based on current account type
if
(
props
.
account
.
type
===
'
oauth
'
||
props
.
account
.
type
===
'
setup-token
'
)
{
// Initialize addMethod based on current account type
(Claude only)
if
(
!
isOpenAI
.
value
&&
(
props
.
account
.
type
===
'
oauth
'
||
props
.
account
.
type
===
'
setup-token
'
)
)
{
addMethod
.
value
=
props
.
account
.
type
as
AddMethod
}
}
else
{
...
...
@@ -160,7 +181,8 @@ watch(() => props.show, (newVal) => {
// Methods
const
resetState
=
()
=>
{
addMethod
.
value
=
'
oauth
'
oauth
.
resetState
()
claudeOAuth
.
resetState
()
openaiOAuth
.
resetState
()
oauthFlowRef
.
value
?.
reset
()
}
...
...
@@ -170,17 +192,54 @@ const handleClose = () => {
const
handleGenerateUrl
=
async
()
=>
{
if
(
!
props
.
account
)
return
await
oauth
.
generateAuthUrl
(
addMethod
.
value
,
props
.
account
.
proxy_id
)
if
(
isOpenAI
.
value
)
{
await
openaiOAuth
.
generateAuthUrl
(
props
.
account
.
proxy_id
)
}
else
{
await
claudeOAuth
.
generateAuthUrl
(
addMethod
.
value
,
props
.
account
.
proxy_id
)
}
}
const
handleExchangeCode
=
async
()
=>
{
if
(
!
props
.
account
)
return
const
authCode
=
oauthFlowRef
.
value
?.
authCode
||
''
if
(
!
authCode
.
trim
()
||
!
oauth
.
sessionId
.
value
)
return
if
(
!
authCode
.
trim
())
return
oauth
.
loading
.
value
=
true
oauth
.
error
.
value
=
''
if
(
isOpenAI
.
value
)
{
// OpenAI OAuth flow
const
sessionId
=
openaiOAuth
.
sessionId
.
value
if
(
!
sessionId
)
return
const
tokenInfo
=
await
openaiOAuth
.
exchangeAuthCode
(
authCode
.
trim
(),
sessionId
,
props
.
account
.
proxy_id
)
if
(
!
tokenInfo
)
return
// Build credentials and extra info
const
credentials
=
openaiOAuth
.
buildCredentials
(
tokenInfo
)
const
extra
=
openaiOAuth
.
buildExtraInfo
(
tokenInfo
)
try
{
// Update account with new credentials
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
{
type
:
'
oauth
'
,
// OpenAI OAuth is always 'oauth' type
credentials
,
extra
})
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
handleClose
()
}
catch
(
error
:
any
)
{
openaiOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
appStore
.
showError
(
openaiOAuth
.
error
.
value
)
}
}
else
{
// Claude OAuth flow
const
sessionId
=
claudeOAuth
.
sessionId
.
value
if
(
!
sessionId
)
return
claudeOAuth
.
loading
.
value
=
true
claudeOAuth
.
error
.
value
=
''
try
{
const
proxyConfig
=
props
.
account
.
proxy_id
?
{
proxy_id
:
props
.
account
.
proxy_id
}
:
{}
...
...
@@ -189,12 +248,12 @@ const handleExchangeCode = async () => {
:
'
/admin/accounts/exchange-setup-token-code
'
const
tokenInfo
=
await
adminAPI
.
accounts
.
exchangeCode
(
endpoint
,
{
session_id
:
oauth
.
sessionId
.
value
,
session_id
:
sessionId
,
code
:
authCode
.
trim
(),
...
proxyConfig
})
const
extra
=
oa
uth
.
buildExtraInfo
(
tokenInfo
)
const
extra
=
claudeOA
uth
.
buildExtraInfo
(
tokenInfo
)
// Update account with new credentials and type
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
{
...
...
@@ -207,18 +266,19 @@ const handleExchangeCode = async () => {
emit
(
'
reauthorized
'
)
handleClose
()
}
catch
(
error
:
any
)
{
oa
uth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
appStore
.
showError
(
oa
uth
.
error
.
value
)
claudeOA
uth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
appStore
.
showError
(
claudeOA
uth
.
error
.
value
)
}
finally
{
oauth
.
loading
.
value
=
false
claudeOAuth
.
loading
.
value
=
false
}
}
}
const
handleCookieAuth
=
async
(
sessionKey
:
string
)
=>
{
if
(
!
props
.
account
)
return
if
(
!
props
.
account
||
isOpenAI
.
value
)
return
oa
uth
.
loading
.
value
=
true
oa
uth
.
error
.
value
=
''
claudeOA
uth
.
loading
.
value
=
true
claudeOA
uth
.
error
.
value
=
''
try
{
const
proxyConfig
=
props
.
account
.
proxy_id
?
{
proxy_id
:
props
.
account
.
proxy_id
}
:
{}
...
...
@@ -232,7 +292,7 @@ const handleCookieAuth = async (sessionKey: string) => {
...
proxyConfig
})
const
extra
=
oa
uth
.
buildExtraInfo
(
tokenInfo
)
const
extra
=
claudeOA
uth
.
buildExtraInfo
(
tokenInfo
)
// Update account with new credentials and type
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
{
...
...
@@ -245,9 +305,9 @@ const handleCookieAuth = async (sessionKey: string) => {
emit
(
'
reauthorized
'
)
handleClose
()
}
catch
(
error
:
any
)
{
oa
uth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.cookieAuthFailed
'
)
claudeOA
uth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.cookieAuthFailed
'
)
}
finally
{
oa
uth
.
loading
.
value
=
false
claudeOA
uth
.
loading
.
value
=
false
}
}
</
script
>
frontend/src/components/common/GroupSelector.vue
View file @
6c469b42
...
...
@@ -8,7 +8,7 @@
class=
"grid grid-cols-2 gap-1 max-h-32 overflow-y-auto p-2 border border-gray-200 dark:border-dark-600 rounded-lg bg-gray-50 dark:bg-dark-800"
>
<label
v-for=
"group in
g
roups"
v-for=
"group in
filteredG
roups"
:key=
"group.id"
class=
"flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white dark:hover:bg-dark-700 cursor-pointer transition-colors"
:title=
"`$
{group.rate_multiplier}x rate · ${group.account_count || 0} accounts`"
...
...
@@ -29,7 +29,7 @@
<span
class=
"text-xs text-gray-400 shrink-0"
>
{{
group
.
account_count
||
0
}}
</span>
</label>
<div
v-if=
"
g
roups.length === 0"
v-if=
"
filteredG
roups.length === 0"
class=
"col-span-2 text-center text-sm text-gray-500 dark:text-gray-400 py-2"
>
No groups available
...
...
@@ -39,12 +39,14 @@
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
GroupBadge
from
'
./GroupBadge.vue
'
import
type
{
Group
}
from
'
@/types
'
import
type
{
Group
,
GroupPlatform
}
from
'
@/types
'
interface
Props
{
modelValue
:
number
[]
groups
:
Group
[]
platform
?:
GroupPlatform
// Optional platform filter
}
const
props
=
defineProps
<
Props
>
()
...
...
@@ -52,6 +54,14 @@ const emit = defineEmits<{
'
update:modelValue
'
:
[
value
:
number
[]]
}
>
()
// Filter groups by platform if specified
const
filteredGroups
=
computed
(()
=>
{
if
(
!
props
.
platform
)
{
return
props
.
groups
}
return
props
.
groups
.
filter
(
g
=>
g
.
platform
===
props
.
platform
)
})
const
handleChange
=
(
groupId
:
number
,
checked
:
boolean
)
=>
{
const
newValue
=
checked
?
[...
props
.
modelValue
,
groupId
]
...
...
frontend/src/composables/useOpenAIOAuth.ts
0 → 100644
View file @
6c469b42
import
{
ref
}
from
'
vue
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
export
interface
OpenAITokenInfo
{
access_token
?:
string
refresh_token
?:
string
id_token
?:
string
token_type
?:
string
expires_in
?:
number
expires_at
?:
number
scope
?:
string
email
?:
string
name
?:
string
// OpenAI specific IDs (extracted from ID Token)
chatgpt_account_id
?:
string
chatgpt_user_id
?:
string
organization_id
?:
string
[
key
:
string
]:
unknown
}
export
function
useOpenAIOAuth
()
{
const
appStore
=
useAppStore
()
// State
const
authUrl
=
ref
(
''
)
const
sessionId
=
ref
(
''
)
const
loading
=
ref
(
false
)
const
error
=
ref
(
''
)
// Reset state
const
resetState
=
()
=>
{
authUrl
.
value
=
''
sessionId
.
value
=
''
loading
.
value
=
false
error
.
value
=
''
}
// Generate auth URL for OpenAI OAuth
const
generateAuthUrl
=
async
(
proxyId
?:
number
|
null
,
redirectUri
?:
string
):
Promise
<
boolean
>
=>
{
loading
.
value
=
true
authUrl
.
value
=
''
sessionId
.
value
=
''
error
.
value
=
''
try
{
const
payload
:
Record
<
string
,
unknown
>
=
{}
if
(
proxyId
)
{
payload
.
proxy_id
=
proxyId
}
if
(
redirectUri
)
{
payload
.
redirect_uri
=
redirectUri
}
const
response
=
await
adminAPI
.
accounts
.
generateAuthUrl
(
'
/admin/openai/generate-auth-url
'
,
payload
)
authUrl
.
value
=
response
.
auth_url
sessionId
.
value
=
response
.
session_id
return
true
}
catch
(
err
:
any
)
{
error
.
value
=
err
.
response
?.
data
?.
detail
||
'
Failed to generate OpenAI auth URL
'
appStore
.
showError
(
error
.
value
)
return
false
}
finally
{
loading
.
value
=
false
}
}
// Exchange auth code for tokens
const
exchangeAuthCode
=
async
(
code
:
string
,
currentSessionId
:
string
,
proxyId
?:
number
|
null
):
Promise
<
OpenAITokenInfo
|
null
>
=>
{
if
(
!
code
.
trim
()
||
!
currentSessionId
)
{
error
.
value
=
'
Missing auth code or session ID
'
return
null
}
loading
.
value
=
true
error
.
value
=
''
try
{
const
payload
:
{
session_id
:
string
;
code
:
string
;
proxy_id
?:
number
}
=
{
session_id
:
currentSessionId
,
code
:
code
.
trim
()
}
if
(
proxyId
)
{
payload
.
proxy_id
=
proxyId
}
const
tokenInfo
=
await
adminAPI
.
accounts
.
exchangeCode
(
'
/admin/openai/exchange-code
'
,
payload
)
return
tokenInfo
as
OpenAITokenInfo
}
catch
(
err
:
any
)
{
error
.
value
=
err
.
response
?.
data
?.
detail
||
'
Failed to exchange OpenAI auth code
'
appStore
.
showError
(
error
.
value
)
return
null
}
finally
{
loading
.
value
=
false
}
}
// Build credentials for OpenAI OAuth account
const
buildCredentials
=
(
tokenInfo
:
OpenAITokenInfo
):
Record
<
string
,
unknown
>
=>
{
const
creds
:
Record
<
string
,
unknown
>
=
{
access_token
:
tokenInfo
.
access_token
,
refresh_token
:
tokenInfo
.
refresh_token
,
token_type
:
tokenInfo
.
token_type
,
expires_in
:
tokenInfo
.
expires_in
,
expires_at
:
tokenInfo
.
expires_at
,
scope
:
tokenInfo
.
scope
}
// Include OpenAI specific IDs (required for forwarding)
if
(
tokenInfo
.
chatgpt_account_id
)
{
creds
.
chatgpt_account_id
=
tokenInfo
.
chatgpt_account_id
}
if
(
tokenInfo
.
chatgpt_user_id
)
{
creds
.
chatgpt_user_id
=
tokenInfo
.
chatgpt_user_id
}
if
(
tokenInfo
.
organization_id
)
{
creds
.
organization_id
=
tokenInfo
.
organization_id
}
return
creds
}
// Build extra info from token response
const
buildExtraInfo
=
(
tokenInfo
:
OpenAITokenInfo
):
Record
<
string
,
string
>
|
undefined
=>
{
const
extra
:
Record
<
string
,
string
>
=
{}
if
(
tokenInfo
.
email
)
{
extra
.
email
=
tokenInfo
.
email
}
if
(
tokenInfo
.
name
)
{
extra
.
name
=
tokenInfo
.
name
}
return
Object
.
keys
(
extra
).
length
>
0
?
extra
:
undefined
}
return
{
// State
authUrl
,
sessionId
,
loading
,
error
,
// Methods
resetState
,
generateAuthUrl
,
exchangeAuthCode
,
buildCredentials
,
buildExtraInfo
}
}
Prev
1
2
3
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment