Commit 6c469b42 authored by shaw's avatar shaw
Browse files

feat: 新增支持codex转发

parent dacf3a2a
...@@ -14,7 +14,9 @@ import ( ...@@ -14,7 +14,9 @@ import (
"strings" "strings"
"time" "time"
"sub2api/internal/model"
"sub2api/internal/pkg/claude" "sub2api/internal/pkg/claude"
"sub2api/internal/pkg/openai"
"sub2api/internal/service/ports" "sub2api/internal/service/ports"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
...@@ -23,6 +25,8 @@ import ( ...@@ -23,6 +25,8 @@ import (
const ( 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 // TestEvent represents a SSE event for account testing
...@@ -38,15 +42,17 @@ type TestEvent struct { ...@@ -38,15 +42,17 @@ type TestEvent struct {
type AccountTestService struct { type AccountTestService struct {
accountRepo ports.AccountRepository accountRepo ports.AccountRepository
oauthService *OAuthService oauthService *OAuthService
claudeUpstream ClaudeUpstream openaiOAuthService *OpenAIOAuthService
httpUpstream ports.HTTPUpstream
} }
// NewAccountTestService creates a new AccountTestService // NewAccountTestService creates a new AccountTestService
func NewAccountTestService(accountRepo ports.AccountRepository, oauthService *OAuthService, claudeUpstream ClaudeUpstream) *AccountTestService { func NewAccountTestService(accountRepo ports.AccountRepository, oauthService *OAuthService, openaiOAuthService *OpenAIOAuthService, httpUpstream ports.HTTPUpstream) *AccountTestService {
return &AccountTestService{ return &AccountTestService{
accountRepo: accountRepo, accountRepo: accountRepo,
oauthService: oauthService, oauthService: oauthService,
claudeUpstream: claudeUpstream, openaiOAuthService: openaiOAuthService,
httpUpstream: httpUpstream,
} }
} }
...@@ -114,6 +120,18 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int ...@@ -114,6 +120,18 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
return s.sendErrorAndEnd(c, "Account not found") 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 // Determine the model to use
testModelID := modelID testModelID := modelID
if testModelID == "" { if testModelID == "" {
...@@ -222,7 +240,7 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int ...@@ -222,7 +240,7 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
proxyURL = account.Proxy.URL() proxyURL = account.Proxy.URL()
} }
resp, err := s.claudeUpstream.Do(req, proxyURL) resp, err := s.httpUpstream.Do(req, proxyURL)
if err != nil { if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
} }
...@@ -234,11 +252,153 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int ...@@ -234,11 +252,153 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
} }
// Process SSE stream // Process SSE stream
return s.processStream(c, resp.Body) return s.processClaudeStream(c, resp.Body)
} }
// processStream processes the SSE stream from Claude API // testOpenAIAccountConnection tests an OpenAI account's connection
func (s *AccountTestService) processStream(c *gin.Context, body io.Reader) error { 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) reader := bufio.NewReader(body)
for { for {
...@@ -291,6 +451,59 @@ func (s *AccountTestService) processStream(c *gin.Context, body io.Reader) error ...@@ -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 // sendEvent sends a SSE event to the client
func (s *AccountTestService) sendEvent(c *gin.Context, event TestEvent) { func (s *AccountTestService) sendEvent(c *gin.Context, event TestEvent) {
eventJSON, _ := json.Marshal(event) eventJSON, _ := json.Marshal(event)
......
...@@ -24,11 +24,6 @@ import ( ...@@ -24,11 +24,6 @@ import (
"github.com/gin-gonic/gin" "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 ( const (
claudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true" claudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true"
claudeAPICountTokensURL = "https://api.anthropic.com/v1/messages/count_tokens?beta=true" claudeAPICountTokensURL = "https://api.anthropic.com/v1/messages/count_tokens?beta=true"
...@@ -87,7 +82,7 @@ type GatewayService struct { ...@@ -87,7 +82,7 @@ type GatewayService struct {
rateLimitService *RateLimitService rateLimitService *RateLimitService
billingCacheService *BillingCacheService billingCacheService *BillingCacheService
identityService *IdentityService identityService *IdentityService
claudeUpstream ClaudeUpstream httpUpstream ports.HTTPUpstream
} }
// NewGatewayService creates a new GatewayService // NewGatewayService creates a new GatewayService
...@@ -102,7 +97,7 @@ func NewGatewayService( ...@@ -102,7 +97,7 @@ func NewGatewayService(
rateLimitService *RateLimitService, rateLimitService *RateLimitService,
billingCacheService *BillingCacheService, billingCacheService *BillingCacheService,
identityService *IdentityService, identityService *IdentityService,
claudeUpstream ClaudeUpstream, httpUpstream ports.HTTPUpstream,
) *GatewayService { ) *GatewayService {
return &GatewayService{ return &GatewayService{
accountRepo: accountRepo, accountRepo: accountRepo,
...@@ -115,7 +110,7 @@ func NewGatewayService( ...@@ -115,7 +110,7 @@ func NewGatewayService(
rateLimitService: rateLimitService, rateLimitService: rateLimitService,
billingCacheService: billingCacheService, billingCacheService: billingCacheService,
identityService: identityService, identityService: identityService,
claudeUpstream: claudeUpstream, httpUpstream: httpUpstream,
} }
} }
...@@ -285,13 +280,13 @@ func (s *GatewayService) SelectAccountForModel(ctx context.Context, groupID *int ...@@ -285,13 +280,13 @@ func (s *GatewayService) SelectAccountForModel(ctx context.Context, groupID *int
} }
} }
// 2. 获取可调度账号列表(排除限流和过载的账号) // 2. 获取可调度账号列表(排除限流和过载的账号,仅限 Anthropic 平台
var accounts []model.Account var accounts []model.Account
var err error var err error
if groupID != nil { if groupID != nil {
accounts, err = s.accountRepo.ListSchedulableByGroupID(ctx, *groupID) accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, model.PlatformAnthropic)
} else { } else {
accounts, err = s.accountRepo.ListSchedulable(ctx) accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, model.PlatformAnthropic)
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("query accounts failed: %w", err) 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 ...@@ -407,7 +402,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *m
} }
// 发送请求 // 发送请求
resp, err := s.claudeUpstream.Do(upstreamReq, proxyURL) resp, err := s.httpUpstream.Do(upstreamReq, proxyURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("upstream request failed: %w", err) return nil, fmt.Errorf("upstream request failed: %w", err)
} }
...@@ -481,7 +476,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex ...@@ -481,7 +476,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
// 设置认证头 // 设置认证头
if tokenType == "oauth" { if tokenType == "oauth" {
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("authorization", "Bearer "+token)
} else { } else {
req.Header.Set("x-api-key", token) req.Header.Set("x-api-key", token)
} }
...@@ -502,8 +497,8 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex ...@@ -502,8 +497,8 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
} }
// 确保必要的headers存在 // 确保必要的headers存在
if req.Header.Get("Content-Type") == "" { if req.Header.Get("content-type") == "" {
req.Header.Set("Content-Type", "application/json") req.Header.Set("content-type", "application/json")
} }
if req.Header.Get("anthropic-version") == "" { if req.Header.Get("anthropic-version") == "" {
req.Header.Set("anthropic-version", "2023-06-01") req.Header.Set("anthropic-version", "2023-06-01")
...@@ -982,7 +977,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, ...@@ -982,7 +977,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
} }
// 发送请求 // 发送请求
resp, err := s.claudeUpstream.Do(upstreamReq, proxyURL) resp, err := s.httpUpstream.Do(upstreamReq, proxyURL)
if err != nil { if err != nil {
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Request failed") s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Request failed")
return fmt.Errorf("upstream request failed: %w", err) return fmt.Errorf("upstream request failed: %w", err)
...@@ -1049,7 +1044,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con ...@@ -1049,7 +1044,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
// 设置认证头 // 设置认证头
if tokenType == "oauth" { if tokenType == "oauth" {
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("authorization", "Bearer "+token)
} else { } else {
req.Header.Set("x-api-key", token) req.Header.Set("x-api-key", token)
} }
...@@ -1073,8 +1068,8 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con ...@@ -1073,8 +1068,8 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
} }
// 确保必要的 headers 存在 // 确保必要的 headers 存在
if req.Header.Get("Content-Type") == "" { if req.Header.Get("content-type") == "" {
req.Header.Set("Content-Type", "application/json") req.Header.Set("content-type", "application/json")
} }
if req.Header.Get("anthropic-version") == "" { if req.Header.Get("anthropic-version") == "" {
req.Header.Set("anthropic-version", "2023-06-01") req.Header.Set("anthropic-version", "2023-06-01")
......
...@@ -114,12 +114,12 @@ func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *ports.Fingerpr ...@@ -114,12 +114,12 @@ func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *ports.Fingerpr
return return
} }
// 设置User-Agent // 设置user-agent
if fp.UserAgent != "" { if fp.UserAgent != "" {
req.Header.Set("User-Agent", fp.UserAgent) req.Header.Set("user-agent", fp.UserAgent)
} }
// 设置x-stainless-*头(使用正确的大小写) // 设置x-stainless-*头
if fp.StainlessLang != "" { if fp.StainlessLang != "" {
req.Header.Set("X-Stainless-Lang", fp.StainlessLang) req.Header.Set("X-Stainless-Lang", fp.StainlessLang)
} }
......
...@@ -284,3 +284,8 @@ func (s *OAuthService) RefreshAccountToken(ctx context.Context, account *model.A ...@@ -284,3 +284,8 @@ func (s *OAuthService) RefreshAccountToken(ctx context.Context, account *model.A
return s.RefreshToken(ctx, refreshToken, proxyURL) return s.RefreshToken(ctx, refreshToken, proxyURL)
} }
// Stop stops the session store cleanup goroutine
func (s *OAuthService) Stop() {
s.sessionStore.Stop()
}
This diff is collapsed.
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()
}
...@@ -27,6 +27,8 @@ type AccountRepository interface { ...@@ -27,6 +27,8 @@ type AccountRepository interface {
ListSchedulable(ctx context.Context) ([]model.Account, error) ListSchedulable(ctx context.Context) ([]model.Account, error)
ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]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 SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error
SetOverloaded(ctx context.Context, id int64, until time.Time) error SetOverloaded(ctx context.Context, id int64, until time.Time) error
......
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)
}
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)
}
...@@ -15,7 +15,9 @@ type Services struct { ...@@ -15,7 +15,9 @@ type Services struct {
BillingCache *BillingCacheService BillingCache *BillingCacheService
Admin AdminService Admin AdminService
Gateway *GatewayService Gateway *GatewayService
OpenAIGateway *OpenAIGatewayService
OAuth *OAuthService OAuth *OAuthService
OpenAIOAuth *OpenAIOAuthService
RateLimit *RateLimitService RateLimit *RateLimitService
AccountUsage *AccountUsageService AccountUsage *AccountUsageService
AccountTest *AccountTestService AccountTest *AccountTestService
......
...@@ -27,6 +27,7 @@ type TokenRefreshService struct { ...@@ -27,6 +27,7 @@ type TokenRefreshService struct {
func NewTokenRefreshService( func NewTokenRefreshService(
accountRepo ports.AccountRepository, accountRepo ports.AccountRepository,
oauthService *OAuthService, oauthService *OAuthService,
openaiOAuthService *OpenAIOAuthService,
cfg *config.Config, cfg *config.Config,
) *TokenRefreshService { ) *TokenRefreshService {
s := &TokenRefreshService{ s := &TokenRefreshService{
...@@ -38,9 +39,7 @@ func NewTokenRefreshService( ...@@ -38,9 +39,7 @@ func NewTokenRefreshService(
// 注册平台特定的刷新器 // 注册平台特定的刷新器
s.refreshers = []TokenRefresher{ s.refreshers = []TokenRefresher{
NewClaudeTokenRefresher(oauthService), NewClaudeTokenRefresher(oauthService),
// 未来可以添加其他平台的刷新器: NewOpenAITokenRefresher(openaiOAuthService),
// NewOpenAITokenRefresher(...),
// NewGeminiTokenRefresher(...),
} }
return s return s
......
...@@ -88,3 +88,54 @@ func (r *ClaudeTokenRefresher) Refresh(ctx context.Context, account *model.Accou ...@@ -88,3 +88,54 @@ func (r *ClaudeTokenRefresher) Refresh(ctx context.Context, account *model.Accou
return newCredentials, nil 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
}
...@@ -37,9 +37,10 @@ func ProvideEmailQueueService(emailService *EmailService) *EmailQueueService { ...@@ -37,9 +37,10 @@ func ProvideEmailQueueService(emailService *EmailService) *EmailQueueService {
func ProvideTokenRefreshService( func ProvideTokenRefreshService(
accountRepo ports.AccountRepository, accountRepo ports.AccountRepository,
oauthService *OAuthService, oauthService *OAuthService,
openaiOAuthService *OpenAIOAuthService,
cfg *config.Config, cfg *config.Config,
) *TokenRefreshService { ) *TokenRefreshService {
svc := NewTokenRefreshService(accountRepo, oauthService, cfg) svc := NewTokenRefreshService(accountRepo, oauthService, openaiOAuthService, cfg)
svc.Start() svc.Start()
return svc return svc
} }
...@@ -60,7 +61,9 @@ var ProviderSet = wire.NewSet( ...@@ -60,7 +61,9 @@ var ProviderSet = wire.NewSet(
NewBillingCacheService, NewBillingCacheService,
NewAdminService, NewAdminService,
NewGatewayService, NewGatewayService,
NewOpenAIGatewayService,
NewOAuthService, NewOAuthService,
NewOpenAIOAuthService,
NewRateLimitService, NewRateLimitService,
NewAccountUsageService, NewAccountUsageService,
NewAccountTestService, NewAccountTestService,
......
<template> <template>
<div v-if="account.type === 'oauth' || account.type === 'setup-token'"> <div v-if="showUsageWindows">
<!-- OAuth accounts: fetch real usage data --> <!-- Anthropic OAuth accounts: fetch real usage data -->
<template v-if="account.type === 'oauth'"> <template v-if="account.platform === 'anthropic' && account.type === 'oauth'">
<!-- Loading state --> <!-- Loading state -->
<div v-if="loading" class="space-y-1.5"> <div v-if="loading" class="space-y-1.5">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
...@@ -63,20 +63,25 @@ ...@@ -63,20 +63,25 @@
</div> </div>
</template> </template>
<!-- Setup Token accounts: show time-based window progress --> <!-- Anthropic Setup Token accounts: show time-based window progress -->
<template v-else-if="account.type === 'setup-token'"> <template v-else-if="account.platform === 'anthropic' && account.type === 'setup-token'">
<SetupTokenTimeWindow :account="account" /> <SetupTokenTimeWindow :account="account" />
</template> </template>
<!-- OpenAI accounts: no usage window API, show dash -->
<template v-else>
<div class="text-xs text-gray-400">-</div>
</template>
</div> </div>
<!-- Non-OAuth accounts --> <!-- Non-OAuth/Setup-Token accounts -->
<div v-else class="text-xs text-gray-400"> <div v-else class="text-xs text-gray-400">
- -
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo } from '@/types' import type { Account, AccountUsageInfo } from '@/types'
import UsageProgressBar from './UsageProgressBar.vue' import UsageProgressBar from './UsageProgressBar.vue'
...@@ -90,9 +95,15 @@ const loading = ref(false) ...@@ -90,9 +95,15 @@ const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const usageInfo = ref<AccountUsageInfo | 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 () => { const loadUsage = async () => {
// Only fetch usage for OAuth accounts (Setup Token uses local time-based calculation) // Only fetch usage for Anthropic OAuth accounts
if (props.account.type !== 'oauth') return // 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 loading.value = true
error.value = null error.value = null
......
...@@ -24,7 +24,7 @@ ...@@ -24,7 +24,7 @@
v-model="editBaseUrl" v-model="editBaseUrl"
type="text" type="text"
class="input" 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> <p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p>
</div> </div>
...@@ -34,7 +34,7 @@ ...@@ -34,7 +34,7 @@
v-model="editApiKey" v-model="editApiKey"
type="password" type="password"
class="input font-mono" 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> <p class="input-hint">{{ t('admin.accounts.leaveEmptyToKeep') }}</p>
</div> </div>
...@@ -286,8 +286,8 @@ ...@@ -286,8 +286,8 @@
</div> </div>
</div> </div>
<!-- Intercept Warmup Requests (all account types) --> <!-- Intercept Warmup Requests (Anthropic only) -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4"> <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 class="flex items-center justify-between">
<div> <div>
<label class="input-label mb-0">{{ t('admin.accounts.interceptWarmupRequests') }}</label> <label class="input-label mb-0">{{ t('admin.accounts.interceptWarmupRequests') }}</label>
...@@ -352,6 +352,7 @@ ...@@ -352,6 +352,7 @@
<GroupSelector <GroupSelector
v-model="form.group_ids" v-model="form.group_ids"
:groups="groups" :groups="groups"
:platform="account?.platform"
/> />
<div class="flex justify-end gap-3 pt-4"> <div class="flex justify-end gap-3 pt-4">
...@@ -428,8 +429,8 @@ const selectedErrorCodes = ref<number[]>([]) ...@@ -428,8 +429,8 @@ const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null) const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
// Common models for whitelist // Common models for whitelist - Anthropic
const commonModels = [ const anthropicModels = [
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' }, { value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' }, { value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' }, { value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
...@@ -440,8 +441,24 @@ const commonModels = [ ...@@ -440,8 +441,24 @@ const commonModels = [
{ value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' } { value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' }
] ]
// Preset mappings for quick add // Common models for whitelist - OpenAI
const presetMappings = [ 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', 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: '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' }, { 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 = [ ...@@ -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' } { 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 // Common HTTP error codes for quick selection
const commonErrorCodes = [ const commonErrorCodes = [
{ value: 401, label: 'Unauthorized' }, { value: 401, label: 'Unauthorized' },
...@@ -492,7 +529,8 @@ watch(() => props.account, (newAccount) => { ...@@ -492,7 +529,8 @@ watch(() => props.account, (newAccount) => {
// Initialize API Key fields for apikey type // Initialize API Key fields for apikey type
if (newAccount.type === 'apikey' && newAccount.credentials) { if (newAccount.type === 'apikey' && newAccount.credentials) {
const credentials = newAccount.credentials as Record<string, unknown> 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 // Load model mappings and detect mode
const existingMappings = credentials.model_mapping as Record<string, string> | undefined const existingMappings = credentials.model_mapping as Record<string, string> | undefined
...@@ -529,7 +567,8 @@ watch(() => props.account, (newAccount) => { ...@@ -529,7 +567,8 @@ watch(() => props.account, (newAccount) => {
selectedErrorCodes.value = [] selectedErrorCodes.value = []
} }
} else { } 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' modelRestrictionMode.value = 'whitelist'
modelMappings.value = [] modelMappings.value = []
allowedModels.value = [] allowedModels.value = []
...@@ -628,7 +667,7 @@ const handleSubmit = async () => { ...@@ -628,7 +667,7 @@ const handleSubmit = async () => {
// For apikey type, handle credentials update // For apikey type, handle credentials update
if (props.account.type === 'apikey') { if (props.account.type === 'apikey') {
const currentCredentials = props.account.credentials as Record<string, unknown> || {} 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() const modelMapping = buildModelMappingObject()
// Always update credentials for apikey type to handle model mapping changes // Always update credentials for apikey type to handle model mapping changes
......
...@@ -7,10 +7,10 @@ ...@@ -7,10 +7,10 @@
</svg> </svg>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">{{ t('admin.accounts.oauth.title') }}</h4> <h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">{{ oauthTitle }}</h4>
<!-- Auth Method Selection --> <!-- 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"> <label class="mb-2 block text-sm font-medium text-blue-800 dark:text-blue-300">
{{ methodLabel }} {{ methodLabel }}
</label> </label>
...@@ -132,7 +132,7 @@ ...@@ -132,7 +132,7 @@
<!-- Manual Authorization Flow --> <!-- Manual Authorization Flow -->
<div v-else class="space-y-4"> <div v-else class="space-y-4">
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300"> <p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
{{ t('admin.accounts.oauth.followSteps') }} {{ oauthFollowSteps }}
</p> </p>
<!-- Step 1: Generate Auth URL --> <!-- Step 1: Generate Auth URL -->
...@@ -143,7 +143,7 @@ ...@@ -143,7 +143,7 @@
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200"> <p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
{{ t('admin.accounts.oauth.step1GenerateUrl') }} {{ oauthStep1GenerateUrl }}
</p> </p>
<button <button
v-if="!authUrl" v-if="!authUrl"
...@@ -159,7 +159,7 @@ ...@@ -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"> <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" /> <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> </svg>
{{ loading ? t('admin.accounts.oauth.generating') : t('admin.accounts.oauth.generateAuthUrl') }} {{ loading ? t('admin.accounts.oauth.generating') : oauthGenerateAuthUrl }}
</button> </button>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
...@@ -206,12 +206,18 @@ ...@@ -206,12 +206,18 @@
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200"> <p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
{{ t('admin.accounts.oauth.step2OpenUrl') }} {{ oauthStep2OpenUrl }}
</p> </p>
<p class="text-sm text-blue-700 dark:text-blue-300"> <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> </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 class="text-xs text-yellow-800 dark:text-yellow-300" v-html="t('admin.accounts.oauth.proxyWarning')">
</p> </p>
</div> </div>
...@@ -227,28 +233,28 @@ ...@@ -227,28 +233,28 @@
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200"> <p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
{{ t('admin.accounts.oauth.step3EnterCode') }} {{ oauthStep3EnterCode }}
</p> </p>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300" v-html="t('admin.accounts.oauth.authCodeDesc')"> <p class="mb-3 text-sm text-blue-700 dark:text-blue-300" v-html="oauthAuthCodeDesc">
</p> </p>
<div> <div>
<label class="input-label"> <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"> <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" /> <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> </svg>
{{ t('admin.accounts.oauth.authCode') }} {{ oauthAuthCode }}
</label> </label>
<textarea <textarea
v-model="authCodeInput" v-model="authCodeInput"
rows="3" rows="3"
class="input w-full font-mono text-sm resize-none" class="input w-full font-mono text-sm resize-none"
:placeholder="t('admin.accounts.oauth.authCodePlaceholder')" :placeholder="oauthAuthCodePlaceholder"
></textarea> ></textarea>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <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"> <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" /> <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> </svg>
{{ t('admin.accounts.oauth.authCodeHint') }} {{ oauthAuthCodeHint }}
</p> </p>
</div> </div>
...@@ -286,6 +292,8 @@ interface Props { ...@@ -286,6 +292,8 @@ interface Props {
showProxyWarning?: boolean showProxyWarning?: boolean
allowMultiple?: boolean allowMultiple?: boolean
methodLabel?: string 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>(), { const props = withDefaults(defineProps<Props>(), {
...@@ -296,7 +304,9 @@ const props = withDefaults(defineProps<Props>(), { ...@@ -296,7 +304,9 @@ const props = withDefaults(defineProps<Props>(), {
showHelp: true, showHelp: true,
showProxyWarning: true, showProxyWarning: true,
allowMultiple: false, allowMultiple: false,
methodLabel: 'Authorization Method' methodLabel: 'Authorization Method',
showCookieOption: true,
platform: 'anthropic'
}) })
const emit = defineEmits<{ const emit = defineEmits<{
...@@ -308,8 +318,35 @@ const emit = defineEmits<{ ...@@ -308,8 +318,35 @@ const emit = defineEmits<{
const { t } = useI18n() 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 // Local state
const inputMethod = ref<AuthInputMethod>('manual') const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'manual')
const authCodeInput = ref('') const authCodeInput = ref('')
const sessionKeyInput = ref('') const sessionKeyInput = ref('')
const showHelpDialog = ref(false) const showHelpDialog = ref(false)
...@@ -327,6 +364,32 @@ watch(inputMethod, (newVal) => { ...@@ -327,6 +364,32 @@ watch(inputMethod, (newVal) => {
emit('update: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 // Methods
const handleGenerateUrl = () => { const handleGenerateUrl = () => {
emit('generate-url') emit('generate-url')
......
...@@ -9,20 +9,25 @@ ...@@ -9,20 +9,25 @@
<!-- Account Info --> <!-- 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="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 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"> <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" /> <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> </svg>
</div> </div>
<div> <div>
<span class="block font-semibold text-gray-900 dark:text-white">{{ account.name }}</span> <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> </div>
</div> </div>
<!-- Add Method Selection --> <!-- Add Method Selection (Claude only) -->
<div> <div v-if="!isOpenAI">
<label class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</label> <label class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</label>
<div class="flex gap-4 mt-2"> <div class="flex gap-4 mt-2">
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
...@@ -50,14 +55,16 @@ ...@@ -50,14 +55,16 @@
<OAuthAuthorizationFlow <OAuthAuthorizationFlow
ref="oauthFlowRef" ref="oauthFlowRef"
:add-method="addMethod" :add-method="addMethod"
:auth-url="oauth.authUrl.value" :auth-url="currentAuthUrl"
:session-id="oauth.sessionId.value" :session-id="currentSessionId"
:loading="oauth.loading.value" :loading="currentLoading"
:error="oauth.error.value" :error="currentError"
:show-help="false" :show-help="!isOpenAI"
:show-proxy-warning="false" :show-proxy-warning="!isOpenAI"
:show-cookie-option="!isOpenAI"
:allow-multiple="false" :allow-multiple="false"
:method-label="t('admin.accounts.inputMethod')" :method-label="t('admin.accounts.inputMethod')"
:platform="isOpenAI ? 'openai' : 'anthropic'"
@generate-url="handleGenerateUrl" @generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth" @cookie-auth="handleCookieAuth"
/> />
...@@ -78,7 +85,7 @@ ...@@ -78,7 +85,7 @@
@click="handleExchangeCode" @click="handleExchangeCode"
> >
<svg <svg
v-if="oauth.loading.value" v-if="currentLoading"
class="animate-spin -ml-1 mr-2 h-4 w-4" class="animate-spin -ml-1 mr-2 h-4 w-4"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
...@@ -86,7 +93,7 @@ ...@@ -86,7 +93,7 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <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> <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> </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> </button>
</div> </div>
</div> </div>
...@@ -99,6 +106,7 @@ import { useI18n } from 'vue-i18n' ...@@ -99,6 +106,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import { useAccountOAuth, type AddMethod, type AuthInputMethod } from '@/composables/useAccountOAuth' import { useAccountOAuth, type AddMethod, type AuthInputMethod } from '@/composables/useAccountOAuth'
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import type { Account } from '@/types' import type { Account } from '@/types'
import Modal from '@/components/common/Modal.vue' import Modal from '@/components/common/Modal.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
...@@ -126,8 +134,9 @@ const emit = defineEmits<{ ...@@ -126,8 +134,9 @@ const emit = defineEmits<{
const appStore = useAppStore() const appStore = useAppStore()
const { t } = useI18n() const { t } = useI18n()
// OAuth composable // OAuth composables - use both Claude and OpenAI
const oauth = useAccountOAuth() const claudeOAuth = useAccountOAuth()
const openaiOAuth = useOpenAIOAuth()
// Refs // Refs
const oauthFlowRef = ref<OAuthFlowExposed | null>(null) const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
...@@ -135,21 +144,33 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null) ...@@ -135,21 +144,33 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
// State // State
const addMethod = ref<AddMethod>('oauth') 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 // Computed
const isManualInputMethod = 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 canExchangeCode = computed(() => {
const authCode = oauthFlowRef.value?.authCode || '' 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 // Watchers
watch(() => props.show, (newVal) => { watch(() => props.show, (newVal) => {
if (newVal && props.account) { if (newVal && props.account) {
// Initialize addMethod based on current account type // Initialize addMethod based on current account type (Claude only)
if (props.account.type === 'oauth' || props.account.type === 'setup-token') { if (!isOpenAI.value && (props.account.type === 'oauth' || props.account.type === 'setup-token')) {
addMethod.value = props.account.type as AddMethod addMethod.value = props.account.type as AddMethod
} }
} else { } else {
...@@ -160,7 +181,8 @@ watch(() => props.show, (newVal) => { ...@@ -160,7 +181,8 @@ watch(() => props.show, (newVal) => {
// Methods // Methods
const resetState = () => { const resetState = () => {
addMethod.value = 'oauth' addMethod.value = 'oauth'
oauth.resetState() claudeOAuth.resetState()
openaiOAuth.resetState()
oauthFlowRef.value?.reset() oauthFlowRef.value?.reset()
} }
...@@ -170,17 +192,54 @@ const handleClose = () => { ...@@ -170,17 +192,54 @@ const handleClose = () => {
const handleGenerateUrl = async () => { const handleGenerateUrl = async () => {
if (!props.account) return 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 () => { const handleExchangeCode = async () => {
if (!props.account) return if (!props.account) return
const authCode = oauthFlowRef.value?.authCode || '' const authCode = oauthFlowRef.value?.authCode || ''
if (!authCode.trim() || !oauth.sessionId.value) return if (!authCode.trim()) return
oauth.loading.value = true if (isOpenAI.value) {
oauth.error.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 { try {
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {} const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
...@@ -189,12 +248,12 @@ const handleExchangeCode = async () => { ...@@ -189,12 +248,12 @@ const handleExchangeCode = async () => {
: '/admin/accounts/exchange-setup-token-code' : '/admin/accounts/exchange-setup-token-code'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, { const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: oauth.sessionId.value, session_id: sessionId,
code: authCode.trim(), code: authCode.trim(),
...proxyConfig ...proxyConfig
}) })
const extra = oauth.buildExtraInfo(tokenInfo) const extra = claudeOAuth.buildExtraInfo(tokenInfo)
// Update account with new credentials and type // Update account with new credentials and type
await adminAPI.accounts.update(props.account.id, { await adminAPI.accounts.update(props.account.id, {
...@@ -207,18 +266,19 @@ const handleExchangeCode = async () => { ...@@ -207,18 +266,19 @@ const handleExchangeCode = async () => {
emit('reauthorized') emit('reauthorized')
handleClose() handleClose()
} catch (error: any) { } catch (error: any) {
oauth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') claudeOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(oauth.error.value) appStore.showError(claudeOAuth.error.value)
} finally { } finally {
oauth.loading.value = false claudeOAuth.loading.value = false
}
} }
} }
const handleCookieAuth = async (sessionKey: string) => { const handleCookieAuth = async (sessionKey: string) => {
if (!props.account) return if (!props.account || isOpenAI.value) return
oauth.loading.value = true claudeOAuth.loading.value = true
oauth.error.value = '' claudeOAuth.error.value = ''
try { try {
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {} const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
...@@ -232,7 +292,7 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -232,7 +292,7 @@ const handleCookieAuth = async (sessionKey: string) => {
...proxyConfig ...proxyConfig
}) })
const extra = oauth.buildExtraInfo(tokenInfo) const extra = claudeOAuth.buildExtraInfo(tokenInfo)
// Update account with new credentials and type // Update account with new credentials and type
await adminAPI.accounts.update(props.account.id, { await adminAPI.accounts.update(props.account.id, {
...@@ -245,9 +305,9 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -245,9 +305,9 @@ const handleCookieAuth = async (sessionKey: string) => {
emit('reauthorized') emit('reauthorized')
handleClose() handleClose()
} catch (error: any) { } catch (error: any) {
oauth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.cookieAuthFailed') claudeOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.cookieAuthFailed')
} finally { } finally {
oauth.loading.value = false claudeOAuth.loading.value = false
} }
} }
</script> </script>
...@@ -8,7 +8,7 @@ ...@@ -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" 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 <label
v-for="group in groups" v-for="group in filteredGroups"
:key="group.id" :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" 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`" :title="`${group.rate_multiplier}x rate · ${group.account_count || 0} accounts`"
...@@ -29,7 +29,7 @@ ...@@ -29,7 +29,7 @@
<span class="text-xs text-gray-400 shrink-0">{{ group.account_count || 0 }}</span> <span class="text-xs text-gray-400 shrink-0">{{ group.account_count || 0 }}</span>
</label> </label>
<div <div
v-if="groups.length === 0" v-if="filteredGroups.length === 0"
class="col-span-2 text-center text-sm text-gray-500 dark:text-gray-400 py-2" class="col-span-2 text-center text-sm text-gray-500 dark:text-gray-400 py-2"
> >
No groups available No groups available
...@@ -39,12 +39,14 @@ ...@@ -39,12 +39,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import GroupBadge from './GroupBadge.vue' import GroupBadge from './GroupBadge.vue'
import type { Group } from '@/types' import type { Group, GroupPlatform } from '@/types'
interface Props { interface Props {
modelValue: number[] modelValue: number[]
groups: Group[] groups: Group[]
platform?: GroupPlatform // Optional platform filter
} }
const props = defineProps<Props>() const props = defineProps<Props>()
...@@ -52,6 +54,14 @@ const emit = defineEmits<{ ...@@ -52,6 +54,14 @@ const emit = defineEmits<{
'update:modelValue': [value: number[]] '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 handleChange = (groupId: number, checked: boolean) => {
const newValue = checked const newValue = checked
? [...props.modelValue, groupId] ? [...props.modelValue, groupId]
......
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
}
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment