Revert "feat: add Claude Code channel support with OAuth integration"
This commit is contained in:
@@ -65,8 +65,6 @@ 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
|
||||
|
||||
@@ -31,6 +31,5 @@ const (
|
||||
APITypeXai
|
||||
APITypeCoze
|
||||
APITypeJimeng
|
||||
APITypeClaudeCode
|
||||
APITypeDummy // this one is only for count, do not add any channel after this
|
||||
)
|
||||
|
||||
@@ -50,7 +50,6 @@ const (
|
||||
ChannelTypeKling = 50
|
||||
ChannelTypeJimeng = 51
|
||||
ChannelTypeVidu = 52
|
||||
ChannelTypeClaudeCode = 53
|
||||
ChannelTypeDummy // this one is only for count, do not add any channel after this
|
||||
|
||||
)
|
||||
@@ -109,5 +108,4 @@ var ChannelBaseURLs = []string{
|
||||
"https://api.klingai.com", //50
|
||||
"https://visual.volcengineapi.com", //51
|
||||
"https://api.vidu.cn", //52
|
||||
"https://api.anthropic.com", //53
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
1
go.mod
1
go.mod
@@ -87,7 +87,6 @@ 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
|
||||
|
||||
2
go.sum
2
go.sum
@@ -231,8 +231,6 @@ 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=
|
||||
|
||||
3
main.go
3
main.go
@@ -86,9 +86,6 @@ 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 {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package claude_code
|
||||
|
||||
var ModelList = []string{
|
||||
"claude-3-5-haiku-20241022",
|
||||
"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"
|
||||
@@ -1,4 +0,0 @@
|
||||
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
|
||||
@@ -9,7 +9,6 @@ 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"
|
||||
@@ -99,8 +98,6 @@ func GetAdaptor(apiType int) channel.Adaptor {
|
||||
return &coze.Adaptor{}
|
||||
case constant.APITypeJimeng:
|
||||
return &jimeng.Adaptor{}
|
||||
case constant.APITypeClaudeCode:
|
||||
return &claude_code.Adaptor{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -120,9 +120,6 @@ 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())
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
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()
|
||||
|
||||
if strings.Contains(authorizationCode, "#") {
|
||||
parts := strings.Split(authorizationCode, "#")
|
||||
if len(parts) > 0 {
|
||||
authorizationCode = parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -13,9 +13,6 @@ var AutomaticDisableKeywords = []string{
|
||||
"The security token included in the request is invalid",
|
||||
"Operation not allowed",
|
||||
"Your account is not authorized",
|
||||
// Claude Code
|
||||
"Invalid bearer token",
|
||||
"OAuth authentication is currently not allowed for this endpoint",
|
||||
}
|
||||
|
||||
func AutomaticDisableKeywordsToString() string {
|
||||
|
||||
@@ -17,6 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
@@ -24,42 +26,38 @@ 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,
|
||||
Tag,
|
||||
Button,
|
||||
Typography,
|
||||
Checkbox,
|
||||
Banner,
|
||||
Modal,
|
||||
ImagePreview,
|
||||
Card,
|
||||
Tag,
|
||||
Avatar,
|
||||
Form,
|
||||
Row,
|
||||
Col,
|
||||
Highlight,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers';
|
||||
import ModelSelectModal from './ModelSelectModal';
|
||||
import JSONEditor from '../../../common/JSONEditor';
|
||||
import { CHANNEL_OPTIONS, CLAUDE_CODE_DEFAULT_SYSTEM_PROMPT } from '../../../../constants';
|
||||
import {
|
||||
IconBolt,
|
||||
IconClose,
|
||||
IconCode,
|
||||
IconGlobe,
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconServer,
|
||||
IconSetting,
|
||||
IconCode,
|
||||
IconGlobe,
|
||||
IconBolt,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
@@ -95,8 +93,6 @@ 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 '请输入渠道对应的鉴权密钥';
|
||||
}
|
||||
@@ -149,10 +145,6 @@ 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 [modelModalVisible, setModelModalVisible] = useState(false);
|
||||
const [fetchedModels, setFetchedModels] = useState([]);
|
||||
const formApiRef = useRef(null);
|
||||
@@ -361,24 +353,6 @@ 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);
|
||||
@@ -502,72 +476,6 @@ 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();
|
||||
|
||||
@@ -880,7 +788,7 @@ const EditChannelModal = (props) => {
|
||||
const batchExtra = batchAllowed ? (
|
||||
<Space>
|
||||
<Checkbox
|
||||
disabled={isEdit || inputs.type === 53}
|
||||
disabled={isEdit}
|
||||
checked={batch}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
@@ -1216,49 +1124,6 @@ const EditChannelModal = (props) => {
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : inputs.type === 53 ? (
|
||||
<>
|
||||
<Form.Input
|
||||
field='access_token'
|
||||
label={isEdit ? t('Access Token(编辑模式下,保存的密钥不会显示)') : t('Access Token')}
|
||||
placeholder={t('sk-ant-xxx')}
|
||||
rules={isEdit ? [] : [{ required: true, message: t('请输入Access Token') }]}
|
||||
autoComplete='new-password'
|
||||
onChange={(value) => {
|
||||
handleInputChange('access_token', value);
|
||||
// 同时更新key字段,格式为access_token|refresh_token
|
||||
const refreshToken = inputs.refresh_token || '';
|
||||
handleInputChange('key', `${value}|${refreshToken}`);
|
||||
}}
|
||||
suffix={
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
theme="light"
|
||||
onClick={handleGenerateOAuth}
|
||||
>
|
||||
{t('生成OAuth授权码')}
|
||||
</Button>
|
||||
}
|
||||
extraText={batchExtra}
|
||||
showClear
|
||||
/>
|
||||
<Form.Input
|
||||
field='refresh_token'
|
||||
label={isEdit ? t('Refresh Token(编辑模式下,保存的密钥不会显示)') : t('Refresh Token')}
|
||||
placeholder={t('sk-ant-xxx(可选)')}
|
||||
rules={[]}
|
||||
autoComplete='new-password'
|
||||
onChange={(value) => {
|
||||
handleInputChange('refresh_token', value);
|
||||
// 同时更新key字段,格式为access_token|refresh_token
|
||||
const accessToken = inputs.access_token || '';
|
||||
handleInputChange('key', `${accessToken}|${value}`);
|
||||
}}
|
||||
extraText={batchExtra}
|
||||
showClear
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Form.Input
|
||||
field='key'
|
||||
@@ -1767,19 +1632,11 @@ const EditChannelModal = (props) => {
|
||||
<Form.TextArea
|
||||
field='system_prompt'
|
||||
label={t('系统提示词')}
|
||||
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}
|
||||
placeholder={t('输入系统提示词,用户的系统提示词将优先于此设置')}
|
||||
onChange={(value) => handleChannelSettingsChange('system_prompt', value)}
|
||||
autosize
|
||||
showClear={inputs.type !== 53}
|
||||
extraText={inputs.type === 53 ? t('Claude Code渠道系统提示词固定为官方CLI身份,不可修改') : t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')}
|
||||
showClear
|
||||
extraText={t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1803,70 +1660,8 @@ const EditChannelModal = (props) => {
|
||||
}}
|
||||
onCancel={() => setModelModalVisible(false)}
|
||||
/>
|
||||
|
||||
{/* OAuth Authorization Modal */}
|
||||
<Modal
|
||||
title={t('生成Claude Code OAuth授权码')}
|
||||
visible={showOAuthModal}
|
||||
onCancel={() => {
|
||||
setShowOAuthModal(false);
|
||||
setAuthorizationCode('');
|
||||
setOauthParams(null);
|
||||
}}
|
||||
onOk={handleExchangeCode}
|
||||
okText={isExchangingCode ? t('交换中...') : t('确认')}
|
||||
cancelText={t('取消')}
|
||||
confirmLoading={isExchangingCode}
|
||||
width={600}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text className="text-sm font-medium mb-2 block">{t('请访问以下授权地址:')}</Text>
|
||||
<div className="p-3 bg-gray-50 rounded-lg border">
|
||||
<Text
|
||||
link
|
||||
underline
|
||||
className="text-sm font-mono break-all cursor-pointer text-blue-600 hover:text-blue-800"
|
||||
onClick={() => {
|
||||
if (oauthParams?.auth_url) {
|
||||
window.open(oauthParams.auth_url, '_blank');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{oauthParams?.auth_url || t('正在生成授权地址...')}
|
||||
</Text>
|
||||
<div className="mt-2">
|
||||
<Text
|
||||
copyable={{ content: oauthParams?.auth_url }}
|
||||
type="tertiary"
|
||||
size="small"
|
||||
>
|
||||
{t('复制链接')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="text-sm font-medium mb-2 block">{t('授权后,请将获得的授权码粘贴到下方:')}</Text>
|
||||
<Input
|
||||
value={authorizationCode}
|
||||
onChange={setAuthorizationCode}
|
||||
placeholder={t('请输入授权码')}
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Banner
|
||||
type="info"
|
||||
description={t('获得授权码后,系统将自动换取access token和refresh token并填充到表单中。')}
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditChannelModal;
|
||||
export default EditChannelModal;
|
||||
@@ -159,14 +159,6 @@ 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.";
|
||||
|
||||
@@ -358,7 +358,6 @@ export function getChannelIcon(channelType) {
|
||||
return <Ollama size={iconSize} />;
|
||||
case 14: // Anthropic Claude
|
||||
case 33: // AWS Claude
|
||||
case 53: // Claude Code
|
||||
return <Claude.Color size={iconSize} />;
|
||||
case 41: // Vertex AI
|
||||
return <Gemini.Color size={iconSize} />;
|
||||
|
||||
Reference in New Issue
Block a user