From bca78beb1bb93fde1d055d6b044c41102e2ebf29 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 26 Jul 2025 18:06:46 +0800 Subject: [PATCH 1/6] feat: add claude code channel --- common/api_type.go | 2 + constant/api_type.go | 1 + constant/channel.go | 2 + controller/claude_oauth.go | 73 +++++ go.mod | 1 + go.sum | 2 + main.go | 3 + relay/channel/claude_code/adaptor.go | 158 +++++++++++ relay/channel/claude_code/constants.go | 15 + relay/channel/claude_code/dto.go | 4 + relay/relay_adaptor.go | 3 + router/api-router.go | 3 + service/claude_oauth.go | 164 +++++++++++ service/claude_token_refresh.go | 94 +++++++ .../channels/modals/EditChannelModal.jsx | 259 ++++++++++++++++-- web/src/constants/channel.constants.js | 8 + web/src/helpers/render.js | 1 + 17 files changed, 766 insertions(+), 27 deletions(-) create mode 100644 controller/claude_oauth.go create mode 100644 relay/channel/claude_code/adaptor.go create mode 100644 relay/channel/claude_code/constants.go create mode 100644 relay/channel/claude_code/dto.go create mode 100644 service/claude_oauth.go create mode 100644 service/claude_token_refresh.go 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 ; From fe9acb6c5960859acf13ca84a48789b7aa22cb16 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 26 Jul 2025 18:40:18 +0800 Subject: [PATCH 2/6] chore: claude code automatic disable --- relay/channel/claude_code/constants.go | 1 - setting/operation_setting/operation_setting.go | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/relay/channel/claude_code/constants.go b/relay/channel/claude_code/constants.go index 7c28e48d..82695be2 100644 --- a/relay/channel/claude_code/constants.go +++ b/relay/channel/claude_code/constants.go @@ -2,7 +2,6 @@ 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", diff --git a/setting/operation_setting/operation_setting.go b/setting/operation_setting/operation_setting.go index ef330d1a..29b77d66 100644 --- a/setting/operation_setting/operation_setting.go +++ b/setting/operation_setting/operation_setting.go @@ -13,6 +13,9 @@ 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 { From 196bafff0387e9c7adffb4964e9718152182bd02 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 31 Jul 2025 10:56:51 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=A2=AB=E7=A6=81?= =?UTF-8?q?=E7=94=A8=E7=9A=84=E6=B8=A0=E9=81=93=E6=97=A0=E6=B3=95=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/channel-test.go | 7 +++++-- model/channel_cache.go | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/controller/channel-test.go b/controller/channel-test.go index 75fec463..3a7c582b 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -332,8 +332,11 @@ func TestChannel(c *gin.Context) { } channel, err := model.CacheGetChannel(channelId) if err != nil { - common.ApiError(c, err) - return + channel, err = model.GetChannelById(channelId, true) + if err != nil { + common.ApiError(c, err) + return + } } //defer func() { // if channel.ChannelInfo.IsMultiKey { diff --git a/model/channel_cache.go b/model/channel_cache.go index 45069ba0..1abc8b85 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -239,6 +239,20 @@ func CacheUpdateChannelStatus(id int, status int) { if channel, ok := channelsIDM[id]; ok { channel.Status = status } + if status != common.ChannelStatusEnabled { + // delete the channel from group2model2channels + for group, model2channels := range group2model2channels { + for model, channels := range model2channels { + for i, channelId := range channels { + if channelId == id { + // remove the channel from the slice + group2model2channels[group][model] = append(channels[:i], channels[i+1:]...) + break + } + } + } + } + } } func CacheUpdateChannel(channel *Channel) { From bd6b811183129709969227f1d4f10121f0df92a0 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 31 Jul 2025 12:54:07 +0800 Subject: [PATCH 4/6] feat: add JSONEditor component for enhanced JSON input handling --- web/src/components/common/JSONEditor.js | 609 ++++++++++++++++++ .../channels/modals/EditChannelModal.jsx | 61 +- 2 files changed, 637 insertions(+), 33 deletions(-) create mode 100644 web/src/components/common/JSONEditor.js diff --git a/web/src/components/common/JSONEditor.js b/web/src/components/common/JSONEditor.js new file mode 100644 index 00000000..d0c159b2 --- /dev/null +++ b/web/src/components/common/JSONEditor.js @@ -0,0 +1,609 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Space, + Button, + Form, + Card, + Typography, + Banner, + Row, + Col, + InputNumber, + Switch, + Select, + Input, +} from '@douyinfe/semi-ui'; +import { + IconCode, + IconEdit, + IconPlus, + IconDelete, + IconSetting, +} from '@douyinfe/semi-icons'; + +const { Text } = Typography; + +const JSONEditor = ({ + value = '', + onChange, + field, + label, + placeholder, + extraText, + showClear = true, + template, + templateLabel, + editorType = 'keyValue', // keyValue, object, region + autosize = true, + rules = [], + formApi = null, + ...props +}) => { + const { t } = useTranslation(); + + // 初始化JSON数据 + const [jsonData, setJsonData] = useState(() => { + // 初始化时解析JSON数据 + if (value && value.trim()) { + try { + const parsed = JSON.parse(value); + return parsed; + } catch (error) { + return {}; + } + } + return {}; + }); + + // 根据键数量决定默认编辑模式 + const [editMode, setEditMode] = useState(() => { + // 如果初始JSON数据的键数量大于10个,则默认使用手动模式 + if (value && value.trim()) { + try { + const parsed = JSON.parse(value); + const keyCount = Object.keys(parsed).length; + return keyCount > 10 ? 'manual' : 'visual'; + } catch (error) { + return 'visual'; + } + } + return 'visual'; + }); + const [jsonError, setJsonError] = useState(''); + + // 数据同步 - 当value变化时总是更新jsonData(如果JSON有效) + useEffect(() => { + try { + const parsed = value && value.trim() ? JSON.parse(value) : {}; + setJsonData(parsed); + setJsonError(''); + } catch (error) { + console.log('JSON解析失败:', error.message); + setJsonError(error.message); + // JSON格式错误时不更新jsonData + } + }, [value]); + + + // 处理可视化编辑的数据变化 + const handleVisualChange = useCallback((newData) => { + setJsonData(newData); + setJsonError(''); + const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2); + + // 通过formApi设置值(如果提供的话) + if (formApi && field) { + formApi.setValue(field, jsonString); + } + + onChange?.(jsonString); + }, [onChange, formApi, field]); + + // 处理手动编辑的数据变化 + const handleManualChange = useCallback((newValue) => { + onChange?.(newValue); + // 验证JSON格式 + if (newValue && newValue.trim()) { + try { + const parsed = JSON.parse(newValue); + setJsonError(''); + // 预先准备可视化数据,但不立即应用 + // 这样切换到可视化模式时数据已经准备好了 + } catch (error) { + setJsonError(error.message); + } + } else { + setJsonError(''); + } + }, [onChange]); + + // 切换编辑模式 + const toggleEditMode = useCallback(() => { + if (editMode === 'visual') { + // 从可视化模式切换到手动模式 + setEditMode('manual'); + } else { + // 从手动模式切换到可视化模式,需要验证JSON + try { + const parsed = value && value.trim() ? JSON.parse(value) : {}; + setJsonData(parsed); + setJsonError(''); + setEditMode('visual'); + } catch (error) { + setJsonError(error.message); + // JSON格式错误时不切换模式 + return; + } + } + }, [editMode, value]); + + // 添加键值对 + const addKeyValue = useCallback(() => { + const newData = { ...jsonData }; + const keys = Object.keys(newData); + let newKey = 'key'; + let counter = 1; + while (newData.hasOwnProperty(newKey)) { + newKey = `key${counter}`; + counter++; + } + newData[newKey] = ''; + handleVisualChange(newData); + }, [jsonData, handleVisualChange]); + + // 删除键值对 + const removeKeyValue = useCallback((keyToRemove) => { + const newData = { ...jsonData }; + delete newData[keyToRemove]; + handleVisualChange(newData); + }, [jsonData, handleVisualChange]); + + // 更新键名 + const updateKey = useCallback((oldKey, newKey) => { + if (oldKey === newKey) return; + const newData = { ...jsonData }; + const value = newData[oldKey]; + delete newData[oldKey]; + newData[newKey] = value; + handleVisualChange(newData); + }, [jsonData, handleVisualChange]); + + // 更新值 + const updateValue = useCallback((key, newValue) => { + const newData = { ...jsonData }; + newData[key] = newValue; + handleVisualChange(newData); + }, [jsonData, handleVisualChange]); + + // 填入模板 + const fillTemplate = useCallback(() => { + if (template) { + const templateString = JSON.stringify(template, null, 2); + + // 通过formApi设置值(如果提供的话) + if (formApi && field) { + formApi.setValue(field, templateString); + } + + // 无论哪种模式都要更新值 + onChange?.(templateString); + + // 如果是可视化模式,同时更新jsonData + if (editMode === 'visual') { + setJsonData(template); + } + + // 清除错误状态 + setJsonError(''); + } + }, [template, onChange, editMode, formApi, field]); + + // 渲染键值对编辑器 + const renderKeyValueEditor = () => { + const entries = Object.entries(jsonData); + + return ( +
+ {entries.length === 0 && ( +
+
+ +
+ + {t('暂无数据,点击下方按钮添加键值对')} + +
+ )} + + {entries.map(([key, value], index) => ( + + + +
+ {t('键名')} + updateKey(key, newKey)} + size="small" + /> +
+ + +
+ {t('值')} + updateValue(key, newValue)} + size="small" + /> +
+ + +
+
+ +
+
+ ))} + +
+ +
+
+ ); + }; + + // 渲染对象编辑器(用于复杂JSON) + const renderObjectEditor = () => { + const entries = Object.entries(jsonData); + + return ( +
+ {entries.length === 0 && ( +
+
+ +
+ + {t('暂无参数,点击下方按钮添加请求参数')} + +
+ )} + + {entries.map(([key, value], index) => ( + + + +
+ {t('参数名')} + updateKey(key, newKey)} + size="small" + /> +
+ + +
+ {t('参数值')} ({typeof value}) + {renderValueInput(key, value)} +
+ + +
+
+ +
+
+ ))} + +
+ +
+
+ ); + }; + + // 渲染参数值输入控件 + const renderValueInput = (key, value) => { + const valueType = typeof value; + + if (valueType === 'boolean') { + return ( +
+ updateValue(key, newValue)} + size="small" + /> + + {value ? t('true') : t('false')} + +
+ ); + } + + if (valueType === 'number') { + return ( + updateValue(key, newValue)} + size="small" + style={{ width: '100%' }} + step={key === 'temperature' ? 0.1 : 1} + precision={key === 'temperature' ? 2 : 0} + placeholder={t('输入数字')} + /> + ); + } + + // 字符串类型或其他类型 + return ( + { + // 尝试转换为适当的类型 + let convertedValue = newValue; + if (newValue === 'true') convertedValue = true; + else if (newValue === 'false') convertedValue = false; + else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') { + convertedValue = Number(newValue); + } + + updateValue(key, convertedValue); + }} + size="small" + /> + ); + }; + + // 渲染区域编辑器(特殊格式) + const renderRegionEditor = () => { + const entries = Object.entries(jsonData); + const defaultEntry = entries.find(([key]) => key === 'default'); + const modelEntries = entries.filter(([key]) => key !== 'default'); + + return ( +
+ {/* 默认区域 */} + +
+ {t('默认区域')} +
+ updateValue('default', value)} + size="small" + /> +
+ + {/* 模型专用区域 */} +
+ {t('模型专用区域')} + {modelEntries.map(([modelName, region], index) => ( + + + +
+ {t('模型名称')} + updateKey(modelName, newKey)} + size="small" + /> +
+ + +
+ {t('区域')} + updateValue(modelName, newValue)} + size="small" + /> +
+ + +
+
+ +
+
+ ))} + +
+ +
+
+
+ ); + }; + + // 渲染可视化编辑器 + const renderVisualEditor = () => { + switch (editorType) { + case 'region': + return renderRegionEditor(); + case 'object': + return renderObjectEditor(); + case 'keyValue': + default: + return renderKeyValueEditor(); + } + }; + + const hasJsonError = jsonError && jsonError.trim() !== ''; + + return ( +
+ {/* Label统一显示在上方 */} + {label && ( +
+ {label} +
+ )} + + {/* 编辑模式切换 */} +
+
+ {editMode === 'visual' && ( + + {t('可视化模式')} + + )} + {editMode === 'manual' && ( + + {t('手动编辑模式')} + + )} +
+
+ {template && templateLabel && ( + + )} + + + + +
+
+ + {/* JSON错误提示 */} + {hasJsonError && ( + + )} + + {/* 编辑器内容 */} + {editMode === 'visual' ? ( +
+ + {renderVisualEditor()} + + {/* 可视化模式下的额外文本显示在下方 */} + {extraText && ( +
+ {extraText} +
+ )} + {/* 隐藏的Form字段用于验证和数据绑定 */} + +
+ ) : ( + + )} + + {/* 额外文本在手动编辑模式下显示 */} + {extraText && editMode === 'manual' && ( +
+ {extraText} +
+ )} +
+ ); +}; + +export default JSONEditor; \ No newline at end of file diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index a3f09166..37e9af75 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -48,6 +48,7 @@ import { } from '@douyinfe/semi-ui'; import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers'; import ModelSelectModal from './ModelSelectModal'; +import JSONEditor from '../../../common/JSONEditor'; import { IconSave, IconClose, @@ -69,7 +70,9 @@ const STATUS_CODE_MAPPING_EXAMPLE = { }; const REGION_EXAMPLE = { - default: 'us-central1', + "default": 'global', + "gemini-1.5-pro-002": "europe-west2", + "gemini-1.5-flash-002": "europe-west2", 'claude-3-5-sonnet-20240620': 'europe-west1', }; @@ -1174,24 +1177,24 @@ const EditChannelModal = (props) => { )} {inputs.type === 41 && ( - handleInputChange('other', value)} rules={[{ required: true, message: t('请填写部署地区') }]} + template={REGION_EXAMPLE} + templateLabel={t('填入模板')} + editorType="region" + formApi={formApiRef.current} extraText={ - handleInputChange('other', JSON.stringify(REGION_EXAMPLE, null, 2))} - > - {t('填入模板')} + + {t('设置默认地区和特定模型的专用地区')} } - showClear /> )} @@ -1447,24 +1450,24 @@ const EditChannelModal = (props) => { showClear /> - handleInputChange('model_mapping', value)} + template={MODEL_MAPPING_EXAMPLE} + templateLabel={t('填入模板')} + editorType="keyValue" + formApi={formApiRef.current} extraText={ - handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))} - > - {t('填入模板')} + + {t('键为请求中的模型名称,值为要替换的模型名称')} } - showClear /> @@ -1554,7 +1557,7 @@ const EditChannelModal = (props) => { showClear /> - { '\n' + JSON.stringify(STATUS_CODE_MAPPING_EXAMPLE, null, 2) } - autosize + value={inputs.status_code_mapping || ''} onChange={(value) => handleInputChange('status_code_mapping', value)} + template={STATUS_CODE_MAPPING_EXAMPLE} + templateLabel={t('填入模板')} + editorType="keyValue" + formApi={formApiRef.current} extraText={ - handleInputChange('status_code_mapping', JSON.stringify(STATUS_CODE_MAPPING_EXAMPLE, null, 2))} - > - {t('填入模板')} + + {t('键为原状态码,值为要复写的状态码,仅影响本地判断')} } - showClear /> @@ -1585,14 +1588,6 @@ const EditChannelModal = (props) => {
{t('渠道额外设置')} -
- window.open('https://github.com/QuantumNous/new-api/blob/main/docs/channel/other_setting.md')} - > - {t('设置说明')} - -
From ce031f7d1532e2ce8217544742c22685be52275e Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 31 Jul 2025 21:16:01 +0800 Subject: [PATCH 5/6] refactor: update error handling to support dynamic error types --- dto/claude.go | 48 +++++++++++++++---- dto/openai_response.go | 64 +++++++++++++++++++++++-- relay/channel/claude/relay-claude.go | 8 ++-- relay/channel/gemini/dto.go | 9 ++-- relay/channel/openai/relay-openai.go | 4 +- relay/channel/openai/relay_responses.go | 4 +- service/convert.go | 22 --------- service/error.go | 2 +- types/error.go | 2 +- 9 files changed, 117 insertions(+), 46 deletions(-) diff --git a/dto/claude.go b/dto/claude.go index 1a7eacb1..ea099df4 100644 --- a/dto/claude.go +++ b/dto/claude.go @@ -2,6 +2,7 @@ package dto import ( "encoding/json" + "fmt" "one-api/common" "one-api/types" ) @@ -284,14 +285,9 @@ func (c *ClaudeRequest) ParseSystem() []ClaudeMediaMessage { return mediaContent } -type ClaudeError struct { - Type string `json:"type,omitempty"` - Message string `json:"message,omitempty"` -} - type ClaudeErrorWithStatusCode struct { - Error ClaudeError `json:"error"` - StatusCode int `json:"status_code"` + Error types.ClaudeError `json:"error"` + StatusCode int `json:"status_code"` LocalError bool } @@ -303,7 +299,7 @@ type ClaudeResponse struct { Completion string `json:"completion,omitempty"` StopReason string `json:"stop_reason,omitempty"` Model string `json:"model,omitempty"` - Error *types.ClaudeError `json:"error,omitempty"` + Error any `json:"error,omitempty"` Usage *ClaudeUsage `json:"usage,omitempty"` Index *int `json:"index,omitempty"` ContentBlock *ClaudeMediaMessage `json:"content_block,omitempty"` @@ -324,6 +320,42 @@ func (c *ClaudeResponse) GetIndex() int { return *c.Index } +// GetClaudeError 从动态错误类型中提取ClaudeError结构 +func (c *ClaudeResponse) GetClaudeError() *types.ClaudeError { + if c.Error == nil { + return nil + } + + switch err := c.Error.(type) { + case types.ClaudeError: + return &err + case *types.ClaudeError: + return err + case map[string]interface{}: + // 处理从JSON解析来的map结构 + claudeErr := &types.ClaudeError{} + if errType, ok := err["type"].(string); ok { + claudeErr.Type = errType + } + if errMsg, ok := err["message"].(string); ok { + claudeErr.Message = errMsg + } + return claudeErr + case string: + // 处理简单字符串错误 + return &types.ClaudeError{ + Type: "error", + Message: err, + } + default: + // 未知类型,尝试转换为字符串 + return &types.ClaudeError{ + Type: "unknown_error", + Message: fmt.Sprintf("%v", err), + } + } +} + type ClaudeUsage struct { InputTokens int `json:"input_tokens"` CacheCreationInputTokens int `json:"cache_creation_input_tokens"` diff --git a/dto/openai_response.go b/dto/openai_response.go index 4e534823..b050cd03 100644 --- a/dto/openai_response.go +++ b/dto/openai_response.go @@ -2,12 +2,18 @@ package dto import ( "encoding/json" + "fmt" "one-api/types" ) type SimpleResponse struct { Usage `json:"usage"` - Error *OpenAIError `json:"error"` + Error any `json:"error"` +} + +// GetOpenAIError 从动态错误类型中提取OpenAIError结构 +func (s *SimpleResponse) GetOpenAIError() *types.OpenAIError { + return GetOpenAIError(s.Error) } type TextResponse struct { @@ -31,10 +37,15 @@ type OpenAITextResponse struct { Object string `json:"object"` Created any `json:"created"` Choices []OpenAITextResponseChoice `json:"choices"` - Error *types.OpenAIError `json:"error,omitempty"` + Error any `json:"error,omitempty"` Usage `json:"usage"` } +// GetOpenAIError 从动态错误类型中提取OpenAIError结构 +func (o *OpenAITextResponse) GetOpenAIError() *types.OpenAIError { + return GetOpenAIError(o.Error) +} + type OpenAIEmbeddingResponseItem struct { Object string `json:"object"` Index int `json:"index"` @@ -217,7 +228,7 @@ type OpenAIResponsesResponse struct { Object string `json:"object"` CreatedAt int `json:"created_at"` Status string `json:"status"` - Error *types.OpenAIError `json:"error,omitempty"` + Error any `json:"error,omitempty"` IncompleteDetails *IncompleteDetails `json:"incomplete_details,omitempty"` Instructions string `json:"instructions"` MaxOutputTokens int `json:"max_output_tokens"` @@ -237,6 +248,11 @@ type OpenAIResponsesResponse struct { Metadata json.RawMessage `json:"metadata"` } +// GetOpenAIError 从动态错误类型中提取OpenAIError结构 +func (o *OpenAIResponsesResponse) GetOpenAIError() *types.OpenAIError { + return GetOpenAIError(o.Error) +} + type IncompleteDetails struct { Reasoning string `json:"reasoning"` } @@ -276,3 +292,45 @@ type ResponsesStreamResponse struct { Delta string `json:"delta,omitempty"` Item *ResponsesOutput `json:"item,omitempty"` } + +// GetOpenAIError 从动态错误类型中提取OpenAIError结构 +func GetOpenAIError(errorField any) *types.OpenAIError { + if errorField == nil { + return nil + } + + switch err := errorField.(type) { + case types.OpenAIError: + return &err + case *types.OpenAIError: + return err + case map[string]interface{}: + // 处理从JSON解析来的map结构 + openaiErr := &types.OpenAIError{} + if errType, ok := err["type"].(string); ok { + openaiErr.Type = errType + } + if errMsg, ok := err["message"].(string); ok { + openaiErr.Message = errMsg + } + if errParam, ok := err["param"].(string); ok { + openaiErr.Param = errParam + } + if errCode, ok := err["code"]; ok { + openaiErr.Code = errCode + } + return openaiErr + case string: + // 处理简单字符串错误 + return &types.OpenAIError{ + Type: "error", + Message: err, + } + default: + // 未知类型,尝试转换为字符串 + return &types.OpenAIError{ + Type: "unknown_error", + Message: fmt.Sprintf("%v", err), + } + } +} diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index f20b573d..64739aa9 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -612,8 +612,8 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud common.SysError("error unmarshalling stream response: " + err.Error()) return types.NewError(err, types.ErrorCodeBadResponseBody) } - if claudeResponse.Error != nil && claudeResponse.Error.Type != "" { - return types.WithClaudeError(*claudeResponse.Error, http.StatusInternalServerError) + if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" { + return types.WithClaudeError(*claudeError, http.StatusInternalServerError) } if info.RelayFormat == relaycommon.RelayFormatClaude { FormatClaudeResponseInfo(requestMode, &claudeResponse, nil, claudeInfo) @@ -704,8 +704,8 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud if err != nil { return types.NewError(err, types.ErrorCodeBadResponseBody) } - if claudeResponse.Error != nil && claudeResponse.Error.Type != "" { - return types.WithClaudeError(*claudeResponse.Error, http.StatusInternalServerError) + if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" { + return types.WithClaudeError(*claudeError, http.StatusInternalServerError) } if requestMode == RequestModeCompletion { completionTokens := service.CountTextToken(claudeResponse.Completion, info.OriginModelName) diff --git a/relay/channel/gemini/dto.go b/relay/channel/gemini/dto.go index b22e092a..a5e41c83 100644 --- a/relay/channel/gemini/dto.go +++ b/relay/channel/gemini/dto.go @@ -1,6 +1,9 @@ package gemini -import "encoding/json" +import ( + "encoding/json" + "one-api/common" +) type GeminiChatRequest struct { Contents []GeminiChatContent `json:"contents"` @@ -32,7 +35,7 @@ func (g *GeminiInlineData) UnmarshalJSON(data []byte) error { MimeTypeSnake string `json:"mime_type"` } - if err := json.Unmarshal(data, &aux); err != nil { + if err := common.Unmarshal(data, &aux); err != nil { return err } @@ -93,7 +96,7 @@ func (p *GeminiPart) UnmarshalJSON(data []byte) error { InlineDataSnake *GeminiInlineData `json:"inline_data,omitempty"` // snake_case variant } - if err := json.Unmarshal(data, &aux); err != nil { + if err := common.Unmarshal(data, &aux); err != nil { return err } diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index 2252b407..f6a04f3a 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -184,8 +184,8 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } - if simpleResponse.Error != nil && simpleResponse.Error.Type != "" { - return nil, types.WithOpenAIError(*simpleResponse.Error, resp.StatusCode) + if oaiError := simpleResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { + return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) } forceFormat := false diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index fd57924b..ef063e7c 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -28,8 +28,8 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } - if responsesResponse.Error != nil { - return nil, types.WithOpenAIError(*responsesResponse.Error, resp.StatusCode) + if oaiError := responsesResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { + return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) } // 写入新的 response body diff --git a/service/convert.go b/service/convert.go index 7d697840..787cc79d 100644 --- a/service/convert.go +++ b/service/convert.go @@ -188,28 +188,6 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re return &openAIRequest, nil } -func OpenAIErrorToClaudeError(openAIError *dto.OpenAIErrorWithStatusCode) *dto.ClaudeErrorWithStatusCode { - claudeError := dto.ClaudeError{ - Type: "new_api_error", - Message: openAIError.Error.Message, - } - return &dto.ClaudeErrorWithStatusCode{ - Error: claudeError, - StatusCode: openAIError.StatusCode, - } -} - -func ClaudeErrorToOpenAIError(claudeError *dto.ClaudeErrorWithStatusCode) *dto.OpenAIErrorWithStatusCode { - openAIError := dto.OpenAIError{ - Message: claudeError.Error.Message, - Type: "new_api_error", - } - return &dto.OpenAIErrorWithStatusCode{ - Error: openAIError, - StatusCode: claudeError.StatusCode, - } -} - func generateStopBlock(index int) *dto.ClaudeResponse { return &dto.ClaudeResponse{ Type: "content_block_stop", diff --git a/service/error.go b/service/error.go index 94d9c250..ad28c90f 100644 --- a/service/error.go +++ b/service/error.go @@ -62,7 +62,7 @@ func ClaudeErrorWrapper(err error, code string, statusCode int) *dto.ClaudeError text = "请求上游地址失败" } } - claudeError := dto.ClaudeError{ + claudeError := types.ClaudeError{ Message: text, Type: "new_api_error", } diff --git a/types/error.go b/types/error.go index 74c3bae5..86aaf692 100644 --- a/types/error.go +++ b/types/error.go @@ -16,8 +16,8 @@ type OpenAIError struct { } type ClaudeError struct { - Message string `json:"message,omitempty"` Type string `json:"type,omitempty"` + Message string `json:"message,omitempty"` } type ErrorType string From 6f56696af2591c594bdd6424c97f0a26adb8dd5e Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 31 Jul 2025 21:27:24 +0800 Subject: [PATCH 6/6] fix: handle authorization code format in ExchangeCode function and update placeholder in EditChannelModal --- service/claude_oauth.go | 11 +++++++++-- .../table/channels/modals/EditChannelModal.jsx | 3 +-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/service/claude_oauth.go b/service/claude_oauth.go index 136269ae..b0e1f84d 100644 --- a/service/claude_oauth.go +++ b/service/claude_oauth.go @@ -60,7 +60,7 @@ type OAuth2Credentials struct { // GetClaudeOAuthConfig returns the Claude OAuth2 configuration func GetClaudeOAuthConfig() *oauth2.Config { authorizeURL, tokenURL, clientID, redirectURI, scopes := getOAuthValues() - + return &oauth2.Config{ ClientID: clientID, RedirectURL: redirectURI, @@ -103,6 +103,13 @@ func GenerateOAuthParams() (*OAuth2Credentials, error) { 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) @@ -141,7 +148,7 @@ func GetClaudeHTTPClient() *http.Client { // 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, diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 8eb3c5a6..cb09b3c9 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -57,7 +57,6 @@ import { 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'; @@ -1853,7 +1852,7 @@ const EditChannelModal = (props) => {