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
Expand all
Hide 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"
...
...
@@ -22,7 +24,9 @@ import (
)
const
(
testClaudeAPIURL
=
"https://api.anthropic.com/v1/messages"
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
...
...
@@ -36,17 +40,19 @@ type TestEvent struct {
// AccountTestService handles account testing operations
type
AccountTestService
struct
{
accountRepo
ports
.
AccountRepository
oauthService
*
OAuthService
claudeUpstream
ClaudeUpstream
accountRepo
ports
.
AccountRepository
oauthService
*
OAuthService
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
,
accountRepo
:
accountRepo
,
oauthService
:
oauthService
,
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,122 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
proxyURL
=
account
.
Proxy
.
URL
()
}
resp
,
err
:=
s
.
claudeUpstream
.
Do
(
req
,
proxyURL
)
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
.
processClaudeStream
(
c
,
resp
.
Body
)
}
// 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
()))
}
...
...
@@ -234,11 +367,38 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
}
// Process SSE stream
return
s
.
processStream
(
c
,
resp
.
Body
)
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
}
// processStream processes the SSE stream from Claude API
func
(
s
*
AccountTestService
)
processStream
(
c
*
gin
.
Context
,
body
io
.
Reader
)
error
{
// process
Claude
Stream processes the SSE stream from Claude API
func
(
s
*
AccountTestService
)
process
Claude
Stream
(
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
This diff is collapsed.
Click to expand it.
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
...
...
@@ -2,30 +2,32 @@ package service
// Services 服务集合容器
type
Services
struct
{
Auth
*
AuthService
User
*
UserService
ApiKey
*
ApiKeyService
Group
*
GroupService
Account
*
AccountService
Proxy
*
ProxyService
Redeem
*
RedeemService
Usage
*
UsageService
Pricing
*
PricingService
Billing
*
BillingService
BillingCache
*
BillingCacheService
Admin
AdminService
Gateway
*
GatewayService
OAuth
*
OAuthService
RateLimit
*
RateLimitService
AccountUsage
*
AccountUsageService
AccountTest
*
AccountTestService
Setting
*
SettingService
Email
*
EmailService
EmailQueue
*
EmailQueueService
Turnstile
*
TurnstileService
Subscription
*
SubscriptionService
Concurrency
*
ConcurrencyService
Identity
*
IdentityService
Update
*
UpdateService
TokenRefresh
*
TokenRefreshService
Auth
*
AuthService
User
*
UserService
ApiKey
*
ApiKeyService
Group
*
GroupService
Account
*
AccountService
Proxy
*
ProxyService
Redeem
*
RedeemService
Usage
*
UsageService
Pricing
*
PricingService
Billing
*
BillingService
BillingCache
*
BillingCacheService
Admin
AdminService
Gateway
*
GatewayService
OpenAIGateway
*
OpenAIGatewayService
OAuth
*
OAuthService
OpenAIOAuth
*
OpenAIOAuthService
RateLimit
*
RateLimitService
AccountUsage
*
AccountUsageService
AccountTest
*
AccountTestService
Setting
*
SettingService
Email
*
EmailService
EmailQueue
*
EmailQueueService
Turnstile
*
TurnstileService
Subscription
*
SubscriptionService
Concurrency
*
ConcurrencyService
Identity
*
IdentityService
Update
*
UpdateService
TokenRefresh
*
TokenRefreshService
}
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
This diff is collapsed.
Click to expand it.
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
.o
penUrlDesc
'
)
}}
{{
oauth
O
penUrlDesc
}}
<
/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
"
>
<!--
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
>
<!--
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,55 +192,93 @@ 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
oauth
.
loading
.
value
=
true
oauth
.
error
.
value
=
''
try
{
const
proxyConfig
=
props
.
account
.
proxy_id
?
{
proxy_id
:
props
.
account
.
proxy_id
}
:
{}
const
endpoint
=
addMethod
.
value
===
'
oauth
'
?
'
/admin/accounts/exchange-code
'
:
'
/admin/accounts/exchange-setup-token-code
'
const
tokenInfo
=
await
adminAPI
.
accounts
.
exchangeCode
(
endpoint
,
{
session_id
:
oauth
.
sessionId
.
value
,
code
:
authCode
.
trim
(),
...
proxyConfig
})
const
extra
=
oauth
.
buildExtraInfo
(
tokenInfo
)
// Update account with new credentials and type
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
{
type
:
addMethod
.
value
,
// Update type based on selected method
credentials
:
tokenInfo
,
extra
})
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
handleClose
()
}
catch
(
error
:
any
)
{
oauth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
appStore
.
showError
(
oauth
.
error
.
value
)
}
finally
{
oauth
.
loading
.
value
=
false
if
(
!
authCode
.
trim
())
return
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
}
:
{}
const
endpoint
=
addMethod
.
value
===
'
oauth
'
?
'
/admin/accounts/exchange-code
'
:
'
/admin/accounts/exchange-setup-token-code
'
const
tokenInfo
=
await
adminAPI
.
accounts
.
exchangeCode
(
endpoint
,
{
session_id
:
sessionId
,
code
:
authCode
.
trim
(),
...
proxyConfig
})
const
extra
=
claudeOAuth
.
buildExtraInfo
(
tokenInfo
)
// Update account with new credentials and type
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
{
type
:
addMethod
.
value
,
// Update type based on selected method
credentials
:
tokenInfo
,
extra
})
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
handleClose
()
}
catch
(
error
:
any
)
{
claudeOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
appStore
.
showError
(
claudeOAuth
.
error
.
value
)
}
finally
{
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
This diff is collapsed.
Click to expand it.
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