diff --git a/common/api_type.go b/common/api_type.go index f045866a..c31f2e2c 100644 --- a/common/api_type.go +++ b/common/api_type.go @@ -65,6 +65,8 @@ func ChannelType2APIType(channelType int) (int, bool) { apiType = constant.APITypeCoze case constant.ChannelTypeJimeng: apiType = constant.APITypeJimeng + case constant.ChannelTypeClaudeCode: + apiType = constant.APITypeClaudeCode } if apiType == -1 { return constant.APITypeOpenAI, false diff --git a/constant/api_type.go b/constant/api_type.go index 6ba5f257..bca5e311 100644 --- a/constant/api_type.go +++ b/constant/api_type.go @@ -31,5 +31,6 @@ const ( APITypeXai APITypeCoze APITypeJimeng + APITypeClaudeCode APITypeDummy // this one is only for count, do not add any channel after this ) diff --git a/constant/channel.go b/constant/channel.go index 2e1cc5b0..cc71caf3 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -50,6 +50,7 @@ const ( ChannelTypeKling = 50 ChannelTypeJimeng = 51 ChannelTypeVidu = 52 + ChannelTypeClaudeCode = 53 ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -108,4 +109,5 @@ var ChannelBaseURLs = []string{ "https://api.klingai.com", //50 "https://visual.volcengineapi.com", //51 "https://api.vidu.cn", //52 + "https://api.anthropic.com", //53 } diff --git a/controller/claude_oauth.go b/controller/claude_oauth.go new file mode 100644 index 00000000..de711b93 --- /dev/null +++ b/controller/claude_oauth.go @@ -0,0 +1,73 @@ +package controller + +import ( + "net/http" + "one-api/common" + "one-api/service" + + "github.com/gin-gonic/gin" +) + +// ExchangeCodeRequest 授权码交换请求 +type ExchangeCodeRequest struct { + AuthorizationCode string `json:"authorization_code" binding:"required"` + CodeVerifier string `json:"code_verifier" binding:"required"` + State string `json:"state" binding:"required"` +} + +// GenerateClaudeOAuthURL 生成Claude OAuth授权URL +func GenerateClaudeOAuthURL(c *gin.Context) { + params, err := service.GenerateOAuthParams() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "生成OAuth授权URL失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "生成OAuth授权URL成功", + "data": params, + }) +} + +// ExchangeClaudeOAuthCode 交换Claude OAuth授权码 +func ExchangeClaudeOAuthCode(c *gin.Context) { + var req ExchangeCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "请求参数错误: " + err.Error(), + }) + return + } + + // 解析授权码 + cleanedCode, err := service.ParseAuthorizationCode(req.AuthorizationCode) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + // 交换token + tokenResult, err := service.ExchangeCode(cleanedCode, req.CodeVerifier, req.State, nil) + if err != nil { + common.SysError("Claude OAuth token exchange failed: " + err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "授权码交换失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "授权码交换成功", + "data": tokenResult, + }) +} diff --git a/go.mod b/go.mod index 94873c88..bae7a4e8 100644 --- a/go.mod +++ b/go.mod @@ -87,6 +87,7 @@ require ( github.com/yusufpapurcu/wmi v1.2.3 // indirect golang.org/x/arch v0.12.0 // indirect golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.22.0 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/go.sum b/go.sum index 74eecd4c..8ded1a03 100644 --- a/go.sum +++ b/go.sum @@ -231,6 +231,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= diff --git a/main.go b/main.go index ca3da601..f49995c2 100644 --- a/main.go +++ b/main.go @@ -86,6 +86,9 @@ func main() { // 数据看板 go model.UpdateQuotaData() + // Start Claude Code token refresh scheduler + service.StartClaudeTokenRefreshScheduler() + if os.Getenv("CHANNEL_UPDATE_FREQUENCY") != "" { frequency, err := strconv.Atoi(os.Getenv("CHANNEL_UPDATE_FREQUENCY")) if err != nil { diff --git a/relay/channel/claude_code/adaptor.go b/relay/channel/claude_code/adaptor.go new file mode 100644 index 00000000..7a0be927 --- /dev/null +++ b/relay/channel/claude_code/adaptor.go @@ -0,0 +1,158 @@ +package claude_code + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/claude" + relaycommon "one-api/relay/common" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +const ( + RequestModeCompletion = 1 + RequestModeMessage = 2 + DefaultSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude." +) + +type Adaptor struct { + RequestMode int +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + // Use configured system prompt if available, otherwise use default + if info.ChannelSetting.SystemPrompt != "" { + request.System = info.ChannelSetting.SystemPrompt + } else { + request.System = DefaultSystemPrompt + } + + return request, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + if strings.HasPrefix(info.UpstreamModelName, "claude-2") || strings.HasPrefix(info.UpstreamModelName, "claude-instant") { + a.RequestMode = RequestModeCompletion + } else { + a.RequestMode = RequestModeMessage + } +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if a.RequestMode == RequestModeMessage { + return fmt.Sprintf("%s/v1/messages", info.BaseUrl), nil + } else { + return fmt.Sprintf("%s/v1/complete", info.BaseUrl), nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + + // Parse accesstoken|refreshtoken format and use only the access token + accessToken := info.ApiKey + if strings.Contains(info.ApiKey, "|") { + parts := strings.Split(info.ApiKey, "|") + if len(parts) >= 1 { + accessToken = parts[0] + } + } + + // Claude Code specific headers - force override + req.Set("Authorization", "Bearer "+accessToken) + // 只有在没有设置的情况下才设置 anthropic-version + if req.Get("anthropic-version") == "" { + req.Set("anthropic-version", "2023-06-01") + } + req.Set("content-type", "application/json") + + // 只有在 user-agent 不包含 claude-cli 时才设置 + userAgent := req.Get("user-agent") + if userAgent == "" || !strings.Contains(strings.ToLower(userAgent), "claude-cli") { + req.Set("user-agent", "claude-cli/1.0.61 (external, cli)") + } + + // 只有在 anthropic-beta 不包含 claude-code 时才设置 + anthropicBeta := req.Get("anthropic-beta") + if anthropicBeta == "" || !strings.Contains(strings.ToLower(anthropicBeta), "claude-code") { + req.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14") + } + // if Anthropic-Dangerous-Direct-Browser-Access + anthropicDangerousDirectBrowserAccess := req.Get("anthropic-dangerous-direct-browser-access") + if anthropicDangerousDirectBrowserAccess == "" { + req.Set("anthropic-dangerous-direct-browser-access", "true") + } + + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + + if a.RequestMode == RequestModeCompletion { + return claude.RequestOpenAI2ClaudeComplete(*request), nil + } else { + claudeRequest, err := claude.RequestOpenAI2ClaudeMessage(*request) + if err != nil { + return nil, err + } + + // Use configured system prompt if available, otherwise use default + if info.ChannelSetting.SystemPrompt != "" { + claudeRequest.System = info.ChannelSetting.SystemPrompt + } else { + claudeRequest.System = DefaultSystemPrompt + } + + return claudeRequest, nil + } +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + err, usage = claude.ClaudeStreamHandler(c, resp, info, a.RequestMode) + } else { + err, usage = claude.ClaudeHandler(c, resp, a.RequestMode, info) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/claude_code/constants.go b/relay/channel/claude_code/constants.go new file mode 100644 index 00000000..7c28e48d --- /dev/null +++ b/relay/channel/claude_code/constants.go @@ -0,0 +1,15 @@ +package claude_code + +var ModelList = []string{ + "claude-3-5-haiku-20241022", + "claude-3-5-sonnet-20240620", + "claude-3-5-sonnet-20241022", + "claude-3-7-sonnet-20250219", + "claude-3-7-sonnet-20250219-thinking", + "claude-sonnet-4-20250514", + "claude-sonnet-4-20250514-thinking", + "claude-opus-4-20250514", + "claude-opus-4-20250514-thinking", +} + +var ChannelName = "claude_code" diff --git a/relay/channel/claude_code/dto.go b/relay/channel/claude_code/dto.go new file mode 100644 index 00000000..68bb9269 --- /dev/null +++ b/relay/channel/claude_code/dto.go @@ -0,0 +1,4 @@ +package claude_code + +// Claude Code uses the same DTO structures as Claude since it's based on the same API +// This file is kept for consistency with the channel structure pattern \ No newline at end of file diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index cc9c5bbb..2456c77f 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -9,6 +9,7 @@ import ( "one-api/relay/channel/baidu" "one-api/relay/channel/baidu_v2" "one-api/relay/channel/claude" + "one-api/relay/channel/claude_code" "one-api/relay/channel/cloudflare" "one-api/relay/channel/cohere" "one-api/relay/channel/coze" @@ -98,6 +99,8 @@ func GetAdaptor(apiType int) channel.Adaptor { return &coze.Adaptor{} case constant.APITypeJimeng: return &jimeng.Adaptor{} + case constant.APITypeClaudeCode: + return &claude_code.Adaptor{} } return nil } diff --git a/router/api-router.go b/router/api-router.go index bc49803a..702fc99f 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -120,6 +120,9 @@ func SetApiRouter(router *gin.Engine) { channelRoute.POST("/batch/tag", controller.BatchSetChannelTag) channelRoute.GET("/tag/models", controller.GetTagModels) channelRoute.POST("/copy/:id", controller.CopyChannel) + // Claude OAuth路由 + channelRoute.GET("/claude/oauth/url", controller.GenerateClaudeOAuthURL) + channelRoute.POST("/claude/oauth/exchange", controller.ExchangeClaudeOAuthCode) } tokenRoute := apiRouter.Group("/token") tokenRoute.Use(middleware.UserAuth()) diff --git a/service/claude_oauth.go b/service/claude_oauth.go new file mode 100644 index 00000000..136269ae --- /dev/null +++ b/service/claude_oauth.go @@ -0,0 +1,164 @@ +package service + +import ( + "context" + "fmt" + "net/http" + "os" + "strings" + "time" + + "golang.org/x/oauth2" +) + +const ( + // Default OAuth configuration values + DefaultAuthorizeURL = "https://claude.ai/oauth/authorize" + DefaultTokenURL = "https://console.anthropic.com/v1/oauth/token" + DefaultClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + DefaultRedirectURI = "https://console.anthropic.com/oauth/code/callback" + DefaultScopes = "user:inference" +) + +// getOAuthValues returns OAuth configuration values from environment variables or defaults +func getOAuthValues() (authorizeURL, tokenURL, clientID, redirectURI, scopes string) { + authorizeURL = os.Getenv("CLAUDE_AUTHORIZE_URL") + if authorizeURL == "" { + authorizeURL = DefaultAuthorizeURL + } + + tokenURL = os.Getenv("CLAUDE_TOKEN_URL") + if tokenURL == "" { + tokenURL = DefaultTokenURL + } + + clientID = os.Getenv("CLAUDE_CLIENT_ID") + if clientID == "" { + clientID = DefaultClientID + } + + redirectURI = os.Getenv("CLAUDE_REDIRECT_URI") + if redirectURI == "" { + redirectURI = DefaultRedirectURI + } + + scopes = os.Getenv("CLAUDE_SCOPES") + if scopes == "" { + scopes = DefaultScopes + } + + return +} + +type OAuth2Credentials struct { + AuthURL string `json:"auth_url"` + CodeVerifier string `json:"code_verifier"` + State string `json:"state"` + CodeChallenge string `json:"code_challenge"` +} + +// GetClaudeOAuthConfig returns the Claude OAuth2 configuration +func GetClaudeOAuthConfig() *oauth2.Config { + authorizeURL, tokenURL, clientID, redirectURI, scopes := getOAuthValues() + + return &oauth2.Config{ + ClientID: clientID, + RedirectURL: redirectURI, + Scopes: strings.Split(scopes, " "), + Endpoint: oauth2.Endpoint{ + AuthURL: authorizeURL, + TokenURL: tokenURL, + }, + } +} + +// getOAuthConfig is kept for backward compatibility +func getOAuthConfig() *oauth2.Config { + return GetClaudeOAuthConfig() +} + +// GenerateOAuthParams generates OAuth authorization URL and related parameters +func GenerateOAuthParams() (*OAuth2Credentials, error) { + config := getOAuthConfig() + + // Generate PKCE parameters + codeVerifier := oauth2.GenerateVerifier() + state := oauth2.GenerateVerifier() // Reuse generator as state + + // Generate authorization URL + authURL := config.AuthCodeURL(state, + oauth2.S256ChallengeOption(codeVerifier), + oauth2.SetAuthURLParam("code", "true"), // Claude-specific parameter + ) + + return &OAuth2Credentials{ + AuthURL: authURL, + CodeVerifier: codeVerifier, + State: state, + CodeChallenge: oauth2.S256ChallengeFromVerifier(codeVerifier), + }, nil +} + +// ExchangeCode +func ExchangeCode(authorizationCode, codeVerifier, state string, client *http.Client) (*oauth2.Token, error) { + config := getOAuthConfig() + + ctx := context.Background() + if client != nil { + ctx = context.WithValue(ctx, oauth2.HTTPClient, client) + } + + token, err := config.Exchange(ctx, authorizationCode, + oauth2.VerifierOption(codeVerifier), + oauth2.SetAuthURLParam("state", state), + ) + if err != nil { + return nil, fmt.Errorf("token exchange failed: %w", err) + } + + return token, nil +} + +func ParseAuthorizationCode(input string) (string, error) { + if input == "" { + return "", fmt.Errorf("please provide a valid authorization code") + } + // URLs are not allowed + if strings.Contains(input, "http") || strings.Contains(input, "https") { + return "", fmt.Errorf("authorization code cannot contain URLs") + } + + return input, nil +} + +// GetClaudeHTTPClient returns a configured HTTP client for Claude OAuth operations +func GetClaudeHTTPClient() *http.Client { + return &http.Client{ + Timeout: 30 * time.Second, + } +} + +// RefreshClaudeToken refreshes a Claude OAuth token using the refresh token +func RefreshClaudeToken(accessToken, refreshToken string) (*oauth2.Token, error) { + config := GetClaudeOAuthConfig() + + // Create token from current values + currentToken := &oauth2.Token{ + AccessToken: accessToken, + RefreshToken: refreshToken, + TokenType: "Bearer", + } + + ctx := context.Background() + if client := GetClaudeHTTPClient(); client != nil { + ctx = context.WithValue(ctx, oauth2.HTTPClient, client) + } + + // Refresh the token + newToken, err := config.TokenSource(ctx, currentToken).Token() + if err != nil { + return nil, fmt.Errorf("failed to refresh Claude token: %w", err) + } + + return newToken, nil +} diff --git a/service/claude_token_refresh.go b/service/claude_token_refresh.go new file mode 100644 index 00000000..5dc35367 --- /dev/null +++ b/service/claude_token_refresh.go @@ -0,0 +1,94 @@ +package service + +import ( + "fmt" + "one-api/common" + "one-api/constant" + "one-api/model" + "strings" + "time" + + "github.com/bytedance/gopkg/util/gopool" +) + +// StartClaudeTokenRefreshScheduler starts the scheduled token refresh for Claude Code channels +func StartClaudeTokenRefreshScheduler() { + ticker := time.NewTicker(5 * time.Minute) + gopool.Go(func() { + defer ticker.Stop() + for range ticker.C { + RefreshClaudeCodeTokens() + } + }) + common.SysLog("Claude Code token refresh scheduler started (5 minute interval)") +} + +// RefreshClaudeCodeTokens refreshes tokens for all active Claude Code channels +func RefreshClaudeCodeTokens() { + var channels []model.Channel + + // Get all active Claude Code channels + err := model.DB.Where("type = ? AND status = ?", constant.ChannelTypeClaudeCode, common.ChannelStatusEnabled).Find(&channels).Error + if err != nil { + common.SysError("Failed to get Claude Code channels: " + err.Error()) + return + } + + refreshCount := 0 + for _, channel := range channels { + if refreshTokenForChannel(&channel) { + refreshCount++ + } + } + + if refreshCount > 0 { + common.SysLog(fmt.Sprintf("Successfully refreshed %d Claude Code channel tokens", refreshCount)) + } +} + +// refreshTokenForChannel attempts to refresh token for a single channel +func refreshTokenForChannel(channel *model.Channel) bool { + // Parse key in format: accesstoken|refreshtoken + if channel.Key == "" || !strings.Contains(channel.Key, "|") { + common.SysError(fmt.Sprintf("Channel %d has invalid key format, expected accesstoken|refreshtoken", channel.Id)) + return false + } + + parts := strings.Split(channel.Key, "|") + if len(parts) < 2 { + common.SysError(fmt.Sprintf("Channel %d has invalid key format, expected accesstoken|refreshtoken", channel.Id)) + return false + } + + accessToken := parts[0] + refreshToken := parts[1] + + if refreshToken == "" { + common.SysError(fmt.Sprintf("Channel %d has empty refresh token", channel.Id)) + return false + } + + // Check if token needs refresh (refresh 30 minutes before expiry) + // if !shouldRefreshToken(accessToken) { + // return false + // } + + // Use shared refresh function + newToken, err := RefreshClaudeToken(accessToken, refreshToken) + if err != nil { + common.SysError(fmt.Sprintf("Failed to refresh token for channel %d: %s", channel.Id, err.Error())) + return false + } + + // Update channel with new tokens + newKey := fmt.Sprintf("%s|%s", newToken.AccessToken, newToken.RefreshToken) + + err = model.DB.Model(channel).Update("key", newKey).Error + if err != nil { + common.SysError(fmt.Sprintf("Failed to update channel %d with new token: %s", channel.Id, err.Error())) + return false + } + + common.SysLog(fmt.Sprintf("Successfully refreshed token for Claude Code channel %d (%s)", channel.Id, channel.Name)) + return true +} diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index a4c8ea76..f037e5a0 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -17,8 +17,6 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useEffect, useState, useRef, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; import { API, showError, @@ -26,36 +24,40 @@ import { showSuccess, verifyJSON, } from '../../../../helpers'; -import { useIsMobile } from '../../../../hooks/common/useIsMobile.js'; -import { CHANNEL_OPTIONS } from '../../../../constants'; import { + Avatar, + Banner, + Button, + Card, + Checkbox, + Col, + Form, + Highlight, + ImagePreview, + Input, + Modal, + Row, SideSheet, Space, Spin, - Button, - Typography, - Checkbox, - Banner, - Modal, - ImagePreview, - Card, Tag, - Avatar, - Form, - Row, - Col, - Highlight, + Typography, } from '@douyinfe/semi-ui'; -import { getChannelModels, copy, getChannelIcon, getModelCategories, modelSelectFilter } from '../../../../helpers'; +import { CHANNEL_OPTIONS, CLAUDE_CODE_DEFAULT_SYSTEM_PROMPT } from '../../../../constants'; import { - IconSave, + IconBolt, IconClose, - IconServer, - IconSetting, IconCode, IconGlobe, - IconBolt, + IconSave, + IconServer, + IconSetting, } from '@douyinfe/semi-icons'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { copy, getChannelIcon, getChannelModels, getModelCategories, modelSelectFilter } from '../../../../helpers'; + +import { useIsMobile } from '../../../../hooks/common/useIsMobile.js'; +import { useTranslation } from 'react-i18next'; const { Text, Title } = Typography; @@ -89,6 +91,8 @@ function type2secretPrompt(type) { return '按照如下格式输入: AccessKey|SecretKey, 如果上游是New API,则直接输ApiKey'; case 51: return '按照如下格式输入: Access Key ID|Secret Access Key'; + case 53: + return '按照如下格式输入:AccessToken|RefreshToken'; default: return '请输入渠道对应的鉴权密钥'; } @@ -141,6 +145,10 @@ const EditChannelModal = (props) => { const [customModel, setCustomModel] = useState(''); const [modalImageUrl, setModalImageUrl] = useState(''); const [isModalOpenurl, setIsModalOpenurl] = useState(false); + const [showOAuthModal, setShowOAuthModal] = useState(false); + const [authorizationCode, setAuthorizationCode] = useState(''); + const [oauthParams, setOauthParams] = useState(null); + const [isExchangingCode, setIsExchangingCode] = useState(false); const formApiRef = useRef(null); const [vertexKeys, setVertexKeys] = useState([]); const [vertexFileList, setVertexFileList] = useState([]); @@ -347,6 +355,24 @@ const EditChannelModal = (props) => { data.system_prompt = ''; } + // 特殊处理Claude Code渠道的密钥拆分和系统提示词 + if (data.type === 53) { + // 拆分密钥 + if (data.key) { + const keyParts = data.key.split('|'); + if (keyParts.length === 2) { + data.access_token = keyParts[0]; + data.refresh_token = keyParts[1]; + } else { + // 如果没有 | 分隔符,表示只有access token + data.access_token = data.key; + data.refresh_token = ''; + } + } + // 强制设置固定系统提示词 + data.system_prompt = CLAUDE_CODE_DEFAULT_SYSTEM_PROMPT; + } + setInputs(data); if (formApiRef.current) { formApiRef.current.setValues(data); @@ -469,6 +495,72 @@ const EditChannelModal = (props) => { } }; + // 生成OAuth授权URL + const handleGenerateOAuth = async () => { + try { + setLoading(true); + const res = await API.get('/api/channel/claude/oauth/url'); + if (res.data.success) { + setOauthParams(res.data.data); + setShowOAuthModal(true); + showSuccess(t('OAuth授权URL生成成功')); + } else { + showError(res.data.message || t('生成OAuth授权URL失败')); + } + } catch (error) { + showError(t('生成OAuth授权URL失败:') + error.message); + } finally { + setLoading(false); + } + }; + + // 交换授权码 + const handleExchangeCode = async () => { + if (!authorizationCode.trim()) { + showError(t('请输入授权码')); + return; + } + + if (!oauthParams) { + showError(t('OAuth参数丢失,请重新生成')); + return; + } + + try { + setIsExchangingCode(true); + const res = await API.post('/api/channel/claude/oauth/exchange', { + authorization_code: authorizationCode, + code_verifier: oauthParams.code_verifier, + state: oauthParams.state, + }); + + if (res.data.success) { + const tokenData = res.data.data; + // 自动填充access token和refresh token + handleInputChange('access_token', tokenData.access_token); + handleInputChange('refresh_token', tokenData.refresh_token); + handleInputChange('key', `${tokenData.access_token}|${tokenData.refresh_token}`); + + // 更新表单字段 + if (formApiRef.current) { + formApiRef.current.setValue('access_token', tokenData.access_token); + formApiRef.current.setValue('refresh_token', tokenData.refresh_token); + } + + setShowOAuthModal(false); + setAuthorizationCode(''); + setOauthParams(null); + showSuccess(t('授权码交换成功,已自动填充tokens')); + } else { + showError(res.data.message || t('授权码交换失败')); + } + } catch (error) { + showError(t('授权码交换失败:') + error.message); + } finally { + setIsExchangingCode(false); + } + }; + useEffect(() => { const modelMap = new Map(); @@ -781,7 +873,7 @@ const EditChannelModal = (props) => { const batchExtra = batchAllowed ? ( { const checked = e.target.checked; @@ -1117,6 +1209,49 @@ const EditChannelModal = (props) => { /> )} + ) : inputs.type === 53 ? ( + <> + { + handleInputChange('access_token', value); + // 同时更新key字段,格式为access_token|refresh_token + const refreshToken = inputs.refresh_token || ''; + handleInputChange('key', `${value}|${refreshToken}`); + }} + suffix={ + + } + extraText={batchExtra} + showClear + /> + { + handleInputChange('refresh_token', value); + // 同时更新key字段,格式为access_token|refresh_token + const accessToken = inputs.access_token || ''; + handleInputChange('key', `${accessToken}|${value}`); + }} + extraText={batchExtra} + showClear + /> + ) : ( { handleChannelSettingsChange('system_prompt', value)} + placeholder={inputs.type === 53 ? CLAUDE_CODE_DEFAULT_SYSTEM_PROMPT : t('输入系统提示词,用户的系统提示词将优先于此设置')} + onChange={(value) => { + if (inputs.type === 53) { + // Claude Code渠道系统提示词固定,不允许修改 + return; + } + handleChannelSettingsChange('system_prompt', value); + }} + disabled={inputs.type === 53} + value={inputs.type === 53 ? CLAUDE_CODE_DEFAULT_SYSTEM_PROMPT : undefined} autosize - showClear - extraText={t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')} + showClear={inputs.type !== 53} + extraText={inputs.type === 53 ? t('Claude Code渠道系统提示词固定为官方CLI身份,不可修改') : t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')} /> @@ -1648,8 +1791,70 @@ const EditChannelModal = (props) => { onVisibleChange={(visible) => setIsModalOpenurl(visible)} /> + + {/* OAuth Authorization Modal */} + { + setShowOAuthModal(false); + setAuthorizationCode(''); + setOauthParams(null); + }} + onOk={handleExchangeCode} + okText={isExchangingCode ? t('交换中...') : t('确认')} + cancelText={t('取消')} + confirmLoading={isExchangingCode} + width={600} + > +
+
+ {t('请访问以下授权地址:')} +
+ { + if (oauthParams?.auth_url) { + window.open(oauthParams.auth_url, '_blank'); + } + }} + > + {oauthParams?.auth_url || t('正在生成授权地址...')} + +
+ + {t('复制链接')} + +
+
+
+ +
+ {t('授权后,请将获得的授权码粘贴到下方:')} + +
+ + +
+
); }; -export default EditChannelModal; \ No newline at end of file +export default EditChannelModal; \ No newline at end of file diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index 43372a25..6035548e 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -159,6 +159,14 @@ export const CHANNEL_OPTIONS = [ color: 'purple', label: 'Vidu', }, + { + value: 53, + color: 'indigo', + label: 'Claude Code', + }, ]; export const MODEL_TABLE_PAGE_SIZE = 10; + +// Claude Code 相关常量 +export const CLAUDE_CODE_DEFAULT_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude."; diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js index bd0a8131..3bdf7c76 100644 --- a/web/src/helpers/render.js +++ b/web/src/helpers/render.js @@ -358,6 +358,7 @@ export function getChannelIcon(channelType) { return ; case 14: // Anthropic Claude case 33: // AWS Claude + case 53: // Claude Code return ; case 41: // Vertex AI return ;