From f995e31d04f4660fce439aa6a4eab24774f53edd Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Thu, 31 Jul 2025 22:08:16 +0800 Subject: [PATCH 01/17] Revert "feat: add Claude Code channel support with OAuth integration" --- 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 | 14 - relay/channel/claude_code/dto.go | 4 - relay/relay_adaptor.go | 3 - router/api-router.go | 3 - service/claude_oauth.go | 171 ------------ service/claude_token_refresh.go | 94 ------- .../operation_setting/operation_setting.go | 3 - .../channels/modals/EditChannelModal.jsx | 257 ++---------------- web/src/constants/channel.constants.js | 8 - web/src/helpers/render.js | 1 - 18 files changed, 26 insertions(+), 774 deletions(-) delete mode 100644 controller/claude_oauth.go delete mode 100644 relay/channel/claude_code/adaptor.go delete mode 100644 relay/channel/claude_code/constants.go delete mode 100644 relay/channel/claude_code/dto.go delete mode 100644 service/claude_oauth.go delete mode 100644 service/claude_token_refresh.go diff --git a/common/api_type.go b/common/api_type.go index c31f2e2c..f045866a 100644 --- a/common/api_type.go +++ b/common/api_type.go @@ -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 diff --git a/constant/api_type.go b/constant/api_type.go index bca5e311..6ba5f257 100644 --- a/constant/api_type.go +++ b/constant/api_type.go @@ -31,6 +31,5 @@ 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 cc71caf3..2e1cc5b0 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -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 } diff --git a/controller/claude_oauth.go b/controller/claude_oauth.go deleted file mode 100644 index de711b93..00000000 --- a/controller/claude_oauth.go +++ /dev/null @@ -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, - }) -} diff --git a/go.mod b/go.mod index bae7a4e8..94873c88 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 8ded1a03..74eecd4c 100644 --- a/go.sum +++ b/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= diff --git a/main.go b/main.go index f49995c2..ca3da601 100644 --- a/main.go +++ b/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 { diff --git a/relay/channel/claude_code/adaptor.go b/relay/channel/claude_code/adaptor.go deleted file mode 100644 index 7a0be927..00000000 --- a/relay/channel/claude_code/adaptor.go +++ /dev/null @@ -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 -} diff --git a/relay/channel/claude_code/constants.go b/relay/channel/claude_code/constants.go deleted file mode 100644 index 82695be2..00000000 --- a/relay/channel/claude_code/constants.go +++ /dev/null @@ -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" diff --git a/relay/channel/claude_code/dto.go b/relay/channel/claude_code/dto.go deleted file mode 100644 index 68bb9269..00000000 --- a/relay/channel/claude_code/dto.go +++ /dev/null @@ -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 \ No newline at end of file diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 2456c77f..cc9c5bbb 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -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 } diff --git a/router/api-router.go b/router/api-router.go index 702fc99f..bc49803a 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -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()) diff --git a/service/claude_oauth.go b/service/claude_oauth.go deleted file mode 100644 index b0e1f84d..00000000 --- a/service/claude_oauth.go +++ /dev/null @@ -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 -} diff --git a/service/claude_token_refresh.go b/service/claude_token_refresh.go deleted file mode 100644 index 5dc35367..00000000 --- a/service/claude_token_refresh.go +++ /dev/null @@ -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 -} diff --git a/setting/operation_setting/operation_setting.go b/setting/operation_setting/operation_setting.go index 29b77d66..ef330d1a 100644 --- a/setting/operation_setting/operation_setting.go +++ b/setting/operation_setting/operation_setting.go @@ -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 { diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index cb09b3c9..37e9af75 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -17,6 +17,8 @@ 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, @@ -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 ? ( { const checked = e.target.checked; @@ -1216,49 +1124,6 @@ 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 - /> - ) : ( { { - 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('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')} /> @@ -1803,70 +1660,8 @@ const EditChannelModal = (props) => { }} onCancel={() => setModelModalVisible(false)} /> - - {/* 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 6035548e..43372a25 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -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."; diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js index 7886f03b..1178d5f9 100644 --- a/web/src/helpers/render.js +++ b/web/src/helpers/render.js @@ -358,7 +358,6 @@ export function getChannelIcon(channelType) { return ; case 14: // Anthropic Claude case 33: // AWS Claude - case 53: // Claude Code return ; case 41: // Vertex AI return ; From 07a92293e4d9d716a42a851e70a4029129a99b43 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 1 Aug 2025 17:04:16 +0800 Subject: [PATCH 02/17] fix: handle case where no response is received from Gemini API --- relay/channel/gemini/relay-gemini-native.go | 6 ++++++ relay/channel/gemini/relay-gemini.go | 7 ++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go index 7d459cc2..29544d1e 100644 --- a/relay/channel/gemini/relay-gemini-native.go +++ b/relay/channel/gemini/relay-gemini-native.go @@ -1,6 +1,7 @@ package gemini import ( + "github.com/pkg/errors" "io" "net/http" "one-api/common" @@ -107,6 +108,7 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn // 直接发送 GeminiChatResponse 响应 err = helper.StringData(c, data) + info.SendResponseCount++ if err != nil { common.LogError(c, err.Error()) } @@ -114,6 +116,10 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn return true }) + if info.SendResponseCount == 0 { + return nil, types.NewOpenAIError(errors.New("no response received from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError) + } + if imageCount != 0 { if usage.CompletionTokens == 0 { usage.CompletionTokens = imageCount * 258 diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 5dac0ce5..1a0b221b 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -827,8 +827,6 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * var usage = &dto.Usage{} var imageCount int - respCount := 0 - helper.StreamScannerHandler(c, resp, info, func(data string) bool { var geminiResponse GeminiChatResponse err := common.UnmarshalJsonStr(data, &geminiResponse) @@ -858,7 +856,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * } } - if respCount == 0 { + if info.SendResponseCount == 0 { // send first response err = handleStream(c, info, helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil)) if err != nil { @@ -873,11 +871,10 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * if isStop { _ = handleStream(c, info, helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, constant.FinishReasonStop)) } - respCount++ return true }) - if respCount == 0 { + if info.SendResponseCount == 0 { // 空补全,报错不计费 // empty response, throw an error return nil, types.NewOpenAIError(errors.New("no response received from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError) From 8df3de9ae585b8eed5cb4e40fcbed4ea86d6f855 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 1 Aug 2025 17:21:25 +0800 Subject: [PATCH 03/17] fix: update JSONEditor to default to manual mode for invalid JSON and add error message for invalid data --- relay/channel/gemini/relay-gemini-native.go | 3 +-- web/src/components/common/JSONEditor.js | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go index 29544d1e..5725a53a 100644 --- a/relay/channel/gemini/relay-gemini-native.go +++ b/relay/channel/gemini/relay-gemini-native.go @@ -108,11 +108,10 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn // 直接发送 GeminiChatResponse 响应 err = helper.StringData(c, data) - info.SendResponseCount++ if err != nil { common.LogError(c, err.Error()) } - + info.SendResponseCount++ return true }) diff --git a/web/src/components/common/JSONEditor.js b/web/src/components/common/JSONEditor.js index d0c159b2..649d5a58 100644 --- a/web/src/components/common/JSONEditor.js +++ b/web/src/components/common/JSONEditor.js @@ -65,7 +65,8 @@ const JSONEditor = ({ const keyCount = Object.keys(parsed).length; return keyCount > 10 ? 'manual' : 'visual'; } catch (error) { - return 'visual'; + // JSON无效时默认显示手动编辑模式 + return 'manual'; } } return 'visual'; @@ -201,6 +202,18 @@ const JSONEditor = ({ // 渲染键值对编辑器 const renderKeyValueEditor = () => { + if (typeof jsonData !== 'object' || jsonData === null) { + return ( +
+
+ +
+ + {t('无效的JSON数据,请检查格式')} + +
+ ); + } const entries = Object.entries(jsonData); return ( From f0945da4fb43fd9b577377299863bdf7910b23a7 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 1 Aug 2025 17:58:21 +0800 Subject: [PATCH 04/17] refactor: simplify streamResponseGeminiChat2OpenAI by removing hasImage return value and optimizing response text handling --- relay/channel/gemini/relay-gemini.go | 32 ++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 1a0b221b..7f8ab303 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -725,10 +725,9 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *GeminiChatResponse) *dt return &fullTextResponse } -func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool, bool) { +func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool) { choices := make([]dto.ChatCompletionsStreamResponseChoice, 0, len(geminiResponse.Candidates)) isStop := false - hasImage := false for _, candidate := range geminiResponse.Candidates { if candidate.FinishReason != nil && *candidate.FinishReason == "STOP" { isStop = true @@ -759,7 +758,6 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.C if strings.HasPrefix(part.InlineData.MimeType, "image") { imgText := "![image](data:" + part.InlineData.MimeType + ";base64," + part.InlineData.Data + ")" texts = append(texts, imgText) - hasImage = true } } else if part.FunctionCall != nil { isTools = true @@ -796,7 +794,7 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.C var response dto.ChatCompletionsStreamResponse response.Object = "chat.completion.chunk" response.Choices = choices - return &response, isStop, hasImage + return &response, isStop } func handleStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.ChatCompletionsStreamResponse) error { @@ -824,6 +822,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * // responseText := "" id := helper.GetResponseID(c) createAt := common.GetTimestamp() + responseText := strings.Builder{} var usage = &dto.Usage{} var imageCount int @@ -835,10 +834,19 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * return false } - response, isStop, hasImage := streamResponseGeminiChat2OpenAI(&geminiResponse) - if hasImage { - imageCount++ + for _, candidate := range geminiResponse.Candidates { + for _, part := range candidate.Content.Parts { + if part.InlineData != nil && part.InlineData.MimeType != "" { + imageCount++ + } + if part.Text != "" { + responseText.WriteString(part.Text) + } + } } + + response, isStop := streamResponseGeminiChat2OpenAI(&geminiResponse) + response.Id = id response.Created = createAt response.Model = info.UpstreamModelName @@ -889,6 +897,16 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * usage.PromptTokensDetails.TextTokens = usage.PromptTokens usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens + if usage.CompletionTokens == 0 { + str := responseText.String() + if len(str) > 0 { + usage = service.ResponseText2Usage(responseText.String(), info.UpstreamModelName, info.PromptTokens) + } else { + // 空补全,不需要使用量 + usage = &dto.Usage{} + } + } + response := helper.GenerateFinalUsageResponse(id, createAt, info.UpstreamModelName, *usage) err := handleFinalStream(c, info, response) if err != nil { From e2429f20f86c234fa2d36077d527d561921df356 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 1 Aug 2025 18:09:20 +0800 Subject: [PATCH 05/17] fix: ensure ChannelIsMultiKey context key is set to false for single key retries --- middleware/distributor.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/middleware/distributor.go b/middleware/distributor.go index fb4a6645..c7a55f4c 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -269,6 +269,9 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode if channel.ChannelInfo.IsMultiKey { common.SetContextKey(c, constant.ContextKeyChannelIsMultiKey, true) common.SetContextKey(c, constant.ContextKeyChannelMultiKeyIndex, index) + } else { + // 必须设置为 false,否则在重试到单个 key 的时候会导致日志显示错误 + common.SetContextKey(c, constant.ContextKeyChannelIsMultiKey, false) } // c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key)) common.SetContextKey(c, constant.ContextKeyChannelKey, key) From 953f1bdc3caeaf1027f286bbb34e4d93691c96f7 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 1 Aug 2025 18:19:28 +0800 Subject: [PATCH 06/17] feat: add admin info to error logging with multi-key support --- controller/relay.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/controller/relay.go b/controller/relay.go index e7318e9b..b5b8f8fe 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -62,6 +62,14 @@ func relayHandler(c *gin.Context, relayMode int) *types.NewAPIError { other["channel_id"] = channelId other["channel_name"] = c.GetString("channel_name") other["channel_type"] = c.GetInt("channel_type") + adminInfo := make(map[string]interface{}) + adminInfo["use_channel"] = c.GetStringSlice("use_channel") + isMultiKey := common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey) + if isMultiKey { + adminInfo["is_multi_key"] = true + adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex) + } + other["admin_info"] = adminInfo model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveError(), tokenId, 0, false, userGroup, other) } From d2183af23f39bd4b45855716178555075cb9350a Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Fri, 1 Aug 2025 22:23:35 +0800 Subject: [PATCH 07/17] feat: convert gemini format to openai chat completions --- relay/channel/gemini/dto.go => dto/gemini.go | 6 +- relay/channel/adapter.go | 1 + relay/channel/ali/adaptor.go | 5 + relay/channel/aws/adaptor.go | 5 + relay/channel/baidu/adaptor.go | 5 + relay/channel/baidu_v2/adaptor.go | 21 +- relay/channel/claude/adaptor.go | 5 + relay/channel/claude_code/adaptor.go | 5 + relay/channel/cloudflare/adaptor.go | 5 + relay/channel/cohere/adaptor.go | 5 + relay/channel/coze/adaptor.go | 5 + relay/channel/deepseek/adaptor.go | 5 + relay/channel/dify/adaptor.go | 5 + relay/channel/gemini/adaptor.go | 16 +- relay/channel/gemini/relay-gemini-native.go | 4 +- relay/channel/gemini/relay-gemini.go | 78 ++--- relay/channel/jimeng/adaptor.go | 8 +- relay/channel/jina/adaptor.go | 5 + relay/channel/mistral/adaptor.go | 5 + relay/channel/mokaai/adaptor.go | 5 + relay/channel/ollama/adaptor.go | 5 + relay/channel/openai/adaptor.go | 11 +- relay/channel/openai/helper.go | 76 ++++ relay/channel/openai/relay-openai.go | 7 + relay/channel/palm/adaptor.go | 5 + relay/channel/perplexity/adaptor.go | 5 + relay/channel/siliconflow/adaptor.go | 5 + relay/channel/tencent/adaptor.go | 5 + relay/channel/vertex/adaptor.go | 4 + relay/channel/volcengine/adaptor.go | 5 + relay/channel/xai/adaptor.go | 5 + relay/channel/xunfei/adaptor.go | 5 + relay/channel/zhipu/adaptor.go | 5 + relay/channel/zhipu_4v/adaptor.go | 5 + relay/gemini_handler.go | 17 +- service/convert.go | 350 +++++++++++++++++++ 36 files changed, 648 insertions(+), 66 deletions(-) rename relay/channel/gemini/dto.go => dto/gemini.go (98%) diff --git a/relay/channel/gemini/dto.go b/dto/gemini.go similarity index 98% rename from relay/channel/gemini/dto.go rename to dto/gemini.go index a5e41c83..f7acd355 100644 --- a/relay/channel/gemini/dto.go +++ b/dto/gemini.go @@ -1,4 +1,4 @@ -package gemini +package dto import ( "encoding/json" @@ -56,7 +56,7 @@ type FunctionCall struct { Arguments any `json:"args"` } -type FunctionResponse struct { +type GeminiFunctionResponse struct { Name string `json:"name"` Response map[string]interface{} `json:"response"` } @@ -81,7 +81,7 @@ type GeminiPart struct { Thought bool `json:"thought,omitempty"` InlineData *GeminiInlineData `json:"inlineData,omitempty"` FunctionCall *FunctionCall `json:"functionCall,omitempty"` - FunctionResponse *FunctionResponse `json:"functionResponse,omitempty"` + FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"` FileData *GeminiFileData `json:"fileData,omitempty"` ExecutableCode *GeminiPartExecutableCode `json:"executableCode,omitempty"` CodeExecutionResult *GeminiPartCodeExecutionResult `json:"codeExecutionResult,omitempty"` diff --git a/relay/channel/adapter.go b/relay/channel/adapter.go index ab8836ba..ec749133 100644 --- a/relay/channel/adapter.go +++ b/relay/channel/adapter.go @@ -26,6 +26,7 @@ type Adaptor interface { GetModelList() []string GetChannelName() string ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) + ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) } type TaskAdaptor interface { diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index d941a1bc..067fac37 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -18,6 +18,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/aws/adaptor.go b/relay/channel/aws/adaptor.go index d3354f00..d7910725 100644 --- a/relay/channel/aws/adaptor.go +++ b/relay/channel/aws/adaptor.go @@ -22,6 +22,11 @@ type Adaptor struct { RequestMode int } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { c.Set("request_model", request.Model) c.Set("converted_request", request) diff --git a/relay/channel/baidu/adaptor.go b/relay/channel/baidu/adaptor.go index 22443354..8396a844 100644 --- a/relay/channel/baidu/adaptor.go +++ b/relay/channel/baidu/adaptor.go @@ -18,6 +18,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/baidu_v2/adaptor.go b/relay/channel/baidu_v2/adaptor.go index 375fd531..b8a4ac2f 100644 --- a/relay/channel/baidu_v2/adaptor.go +++ b/relay/channel/baidu_v2/adaptor.go @@ -18,6 +18,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") @@ -43,15 +48,15 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { channel.SetupApiRequestHeader(info, c, req) - keyParts := strings.Split(info.ApiKey, "|") + keyParts := strings.Split(info.ApiKey, "|") if len(keyParts) == 0 || keyParts[0] == "" { - return errors.New("invalid API key: authorization token is required") - } - if len(keyParts) > 1 { - if keyParts[1] != "" { - req.Set("appid", keyParts[1]) - } - } + return errors.New("invalid API key: authorization token is required") + } + if len(keyParts) > 1 { + if keyParts[1] != "" { + req.Set("appid", keyParts[1]) + } + } req.Set("Authorization", "Bearer "+keyParts[0]) return nil } diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go index 540742d6..0f7a9414 100644 --- a/relay/channel/claude/adaptor.go +++ b/relay/channel/claude/adaptor.go @@ -24,6 +24,11 @@ type Adaptor struct { RequestMode int } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { return request, nil } diff --git a/relay/channel/claude_code/adaptor.go b/relay/channel/claude_code/adaptor.go index 7a0be927..a5926f9d 100644 --- a/relay/channel/claude_code/adaptor.go +++ b/relay/channel/claude_code/adaptor.go @@ -25,6 +25,11 @@ type Adaptor struct { RequestMode int } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + 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 != "" { diff --git a/relay/channel/cloudflare/adaptor.go b/relay/channel/cloudflare/adaptor.go index 6e59ad71..74a65ba4 100644 --- a/relay/channel/cloudflare/adaptor.go +++ b/relay/channel/cloudflare/adaptor.go @@ -18,6 +18,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/cohere/adaptor.go b/relay/channel/cohere/adaptor.go index 4f3a96c3..887f9efd 100644 --- a/relay/channel/cohere/adaptor.go +++ b/relay/channel/cohere/adaptor.go @@ -17,6 +17,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/coze/adaptor.go b/relay/channel/coze/adaptor.go index fe5f5f00..658c6193 100644 --- a/relay/channel/coze/adaptor.go +++ b/relay/channel/coze/adaptor.go @@ -18,6 +18,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *common.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + // ConvertAudioRequest implements channel.Adaptor. func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *common.RelayInfo, request dto.AudioRequest) (io.Reader, error) { return nil, errors.New("not implemented") diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go index edfc7fd3..ac8ea18f 100644 --- a/relay/channel/deepseek/adaptor.go +++ b/relay/channel/deepseek/adaptor.go @@ -19,6 +19,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/dify/adaptor.go b/relay/channel/dify/adaptor.go index 4ad16766..8c7898c9 100644 --- a/relay/channel/dify/adaptor.go +++ b/relay/channel/dify/adaptor.go @@ -24,6 +24,11 @@ type Adaptor struct { BotType int } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index 2b7b7e39..20d43020 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -20,6 +20,10 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + return request, nil +} + func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { adaptor := openai.Adaptor{} oaiReq, err := adaptor.ConvertClaudeRequest(c, info, req) @@ -51,13 +55,13 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf } // build gemini imagen request - geminiRequest := GeminiImageRequest{ - Instances: []GeminiImageInstance{ + geminiRequest := dto.GeminiImageRequest{ + Instances: []dto.GeminiImageInstance{ { Prompt: request.Prompt, }, }, - Parameters: GeminiImageParameters{ + Parameters: dto.GeminiImageParameters{ SampleCount: request.N, AspectRatio: aspectRatio, PersonGeneration: "allow_adult", // default allow adult @@ -138,9 +142,9 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela } // only process the first input - geminiRequest := GeminiEmbeddingRequest{ - Content: GeminiChatContent{ - Parts: []GeminiPart{ + geminiRequest := dto.GeminiEmbeddingRequest{ + Content: dto.GeminiChatContent{ + Parts: []dto.GeminiPart{ { Text: inputs[0], }, diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go index 7d459cc2..2060fd8c 100644 --- a/relay/channel/gemini/relay-gemini-native.go +++ b/relay/channel/gemini/relay-gemini-native.go @@ -28,7 +28,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re } // 解析为 Gemini 原生响应格式 - var geminiResponse GeminiChatResponse + var geminiResponse dto.GeminiChatResponse err = common.Unmarshal(responseBody, &geminiResponse) if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) @@ -71,7 +71,7 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn responseText := strings.Builder{} helper.StreamScannerHandler(c, resp, info, func(data string) bool { - var geminiResponse GeminiChatResponse + var geminiResponse dto.GeminiChatResponse err := common.UnmarshalJsonStr(data, &geminiResponse) if err != nil { common.LogError(c, "error unmarshalling stream response: "+err.Error()) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 5dac0ce5..4065259f 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -81,7 +81,7 @@ func clampThinkingBudget(modelName string, budget int) int { return budget } -func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayInfo) { +func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo) { if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { modelName := info.UpstreamModelName isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") && @@ -93,7 +93,7 @@ func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayIn if len(parts) == 2 && parts[1] != "" { if budgetTokens, err := strconv.Atoi(parts[1]); err == nil { clampedBudget := clampThinkingBudget(modelName, budgetTokens) - geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ ThinkingBudget: common.GetPointer(clampedBudget), IncludeThoughts: true, } @@ -113,11 +113,11 @@ func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayIn } if isUnsupported { - geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ IncludeThoughts: true, } } else { - geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ IncludeThoughts: true, } if geminiRequest.GenerationConfig.MaxOutputTokens > 0 { @@ -128,7 +128,7 @@ func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayIn } } else if strings.HasSuffix(modelName, "-nothinking") { if !isNew25Pro { - geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ ThinkingBudget: common.GetPointer(0), } } @@ -137,11 +137,11 @@ func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayIn } // Setting safety to the lowest possible values since Gemini is already powerless enough -func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*GeminiChatRequest, error) { +func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*dto.GeminiChatRequest, error) { - geminiRequest := GeminiChatRequest{ - Contents: make([]GeminiChatContent, 0, len(textRequest.Messages)), - GenerationConfig: GeminiChatGenerationConfig{ + geminiRequest := dto.GeminiChatRequest{ + Contents: make([]dto.GeminiChatContent, 0, len(textRequest.Messages)), + GenerationConfig: dto.GeminiChatGenerationConfig{ Temperature: textRequest.Temperature, TopP: textRequest.TopP, MaxOutputTokens: textRequest.MaxTokens, @@ -158,9 +158,9 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon ThinkingAdaptor(&geminiRequest, info) - safetySettings := make([]GeminiChatSafetySettings, 0, len(SafetySettingList)) + safetySettings := make([]dto.GeminiChatSafetySettings, 0, len(SafetySettingList)) for _, category := range SafetySettingList { - safetySettings = append(safetySettings, GeminiChatSafetySettings{ + safetySettings = append(safetySettings, dto.GeminiChatSafetySettings{ Category: category, Threshold: model_setting.GetGeminiSafetySetting(category), }) @@ -198,17 +198,17 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon functions = append(functions, tool.Function) } if codeExecution { - geminiRequest.Tools = append(geminiRequest.Tools, GeminiChatTool{ + geminiRequest.Tools = append(geminiRequest.Tools, dto.GeminiChatTool{ CodeExecution: make(map[string]string), }) } if googleSearch { - geminiRequest.Tools = append(geminiRequest.Tools, GeminiChatTool{ + geminiRequest.Tools = append(geminiRequest.Tools, dto.GeminiChatTool{ GoogleSearch: make(map[string]string), }) } if len(functions) > 0 { - geminiRequest.Tools = append(geminiRequest.Tools, GeminiChatTool{ + geminiRequest.Tools = append(geminiRequest.Tools, dto.GeminiChatTool{ FunctionDeclarations: functions, }) } @@ -238,7 +238,7 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon continue } else if message.Role == "tool" || message.Role == "function" { if len(geminiRequest.Contents) == 0 || geminiRequest.Contents[len(geminiRequest.Contents)-1].Role == "model" { - geminiRequest.Contents = append(geminiRequest.Contents, GeminiChatContent{ + geminiRequest.Contents = append(geminiRequest.Contents, dto.GeminiChatContent{ Role: "user", }) } @@ -265,18 +265,18 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon } } - functionResp := &FunctionResponse{ + functionResp := &dto.GeminiFunctionResponse{ Name: name, Response: contentMap, } - *parts = append(*parts, GeminiPart{ + *parts = append(*parts, dto.GeminiPart{ FunctionResponse: functionResp, }) continue } - var parts []GeminiPart - content := GeminiChatContent{ + var parts []dto.GeminiPart + content := dto.GeminiChatContent{ Role: message.Role, } // isToolCall := false @@ -290,8 +290,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon return nil, fmt.Errorf("invalid arguments for function %s, args: %s", call.Function.Name, call.Function.Arguments) } } - toolCall := GeminiPart{ - FunctionCall: &FunctionCall{ + toolCall := dto.GeminiPart{ + FunctionCall: &dto.FunctionCall{ FunctionName: call.Function.Name, Arguments: args, }, @@ -308,7 +308,7 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon if part.Text == "" { continue } - parts = append(parts, GeminiPart{ + parts = append(parts, dto.GeminiPart{ Text: part.Text, }) } else if part.Type == dto.ContentTypeImageURL { @@ -331,8 +331,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", fileData.MimeType, url, getSupportedMimeTypesList()) } - parts = append(parts, GeminiPart{ - InlineData: &GeminiInlineData{ + parts = append(parts, dto.GeminiPart{ + InlineData: &dto.GeminiInlineData{ MimeType: fileData.MimeType, // 使用原始的 MimeType,因为大小写可能对API有意义 Data: fileData.Base64Data, }, @@ -342,8 +342,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon if err != nil { return nil, fmt.Errorf("decode base64 image data failed: %s", err.Error()) } - parts = append(parts, GeminiPart{ - InlineData: &GeminiInlineData{ + parts = append(parts, dto.GeminiPart{ + InlineData: &dto.GeminiInlineData{ MimeType: format, Data: base64String, }, @@ -357,8 +357,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon if err != nil { return nil, fmt.Errorf("decode base64 file data failed: %s", err.Error()) } - parts = append(parts, GeminiPart{ - InlineData: &GeminiInlineData{ + parts = append(parts, dto.GeminiPart{ + InlineData: &dto.GeminiInlineData{ MimeType: format, Data: base64String, }, @@ -371,8 +371,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon if err != nil { return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error()) } - parts = append(parts, GeminiPart{ - InlineData: &GeminiInlineData{ + parts = append(parts, dto.GeminiPart{ + InlineData: &dto.GeminiInlineData{ MimeType: "audio/" + part.GetInputAudio().Format, Data: base64String, }, @@ -392,8 +392,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon } if len(system_content) > 0 { - geminiRequest.SystemInstructions = &GeminiChatContent{ - Parts: []GeminiPart{ + geminiRequest.SystemInstructions = &dto.GeminiChatContent{ + Parts: []dto.GeminiPart{ { Text: strings.Join(system_content, "\n"), }, @@ -636,7 +636,7 @@ func unescapeMapOrSlice(data interface{}) interface{} { return data } -func getResponseToolCall(item *GeminiPart) *dto.ToolCallResponse { +func getResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse { var argsBytes []byte var err error if result, ok := item.FunctionCall.Arguments.(map[string]interface{}); ok { @@ -658,7 +658,7 @@ func getResponseToolCall(item *GeminiPart) *dto.ToolCallResponse { } } -func responseGeminiChat2OpenAI(c *gin.Context, response *GeminiChatResponse) *dto.OpenAITextResponse { +func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse) *dto.OpenAITextResponse { fullTextResponse := dto.OpenAITextResponse{ Id: helper.GetResponseID(c), Object: "chat.completion", @@ -725,7 +725,7 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *GeminiChatResponse) *dt return &fullTextResponse } -func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool, bool) { +func streamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool, bool) { choices := make([]dto.ChatCompletionsStreamResponseChoice, 0, len(geminiResponse.Candidates)) isStop := false hasImage := false @@ -830,7 +830,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * respCount := 0 helper.StreamScannerHandler(c, resp, info, func(data string) bool { - var geminiResponse GeminiChatResponse + var geminiResponse dto.GeminiChatResponse err := common.UnmarshalJsonStr(data, &geminiResponse) if err != nil { common.LogError(c, "error unmarshalling stream response: "+err.Error()) @@ -913,7 +913,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R if common.DebugEnabled { println(string(responseBody)) } - var geminiResponse GeminiChatResponse + var geminiResponse dto.GeminiChatResponse err = common.Unmarshal(responseBody, &geminiResponse) if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) @@ -959,7 +959,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } - var geminiResponse GeminiEmbeddingResponse + var geminiResponse dto.GeminiEmbeddingResponse if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil { return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } @@ -1005,7 +1005,7 @@ func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http. } _ = resp.Body.Close() - var geminiResponse GeminiImageResponse + var geminiResponse dto.GeminiImageResponse if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil { return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } diff --git a/relay/channel/jimeng/adaptor.go b/relay/channel/jimeng/adaptor.go index 0b743879..ff9ac678 100644 --- a/relay/channel/jimeng/adaptor.go +++ b/relay/channel/jimeng/adaptor.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/gin-gonic/gin" "io" "net/http" "one-api/dto" @@ -13,11 +12,18 @@ import ( relaycommon "one-api/relay/common" relayconstant "one-api/relay/constant" "one-api/types" + + "github.com/gin-gonic/gin" ) type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { return nil, errors.New("not implemented") } diff --git a/relay/channel/jina/adaptor.go b/relay/channel/jina/adaptor.go index 408a5c6e..bf318aa7 100644 --- a/relay/channel/jina/adaptor.go +++ b/relay/channel/jina/adaptor.go @@ -19,6 +19,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/mistral/adaptor.go b/relay/channel/mistral/adaptor.go index 434a1031..45cb3290 100644 --- a/relay/channel/mistral/adaptor.go +++ b/relay/channel/mistral/adaptor.go @@ -16,6 +16,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/mokaai/adaptor.go b/relay/channel/mokaai/adaptor.go index b0b54b0c..37db2aec 100644 --- a/relay/channel/mokaai/adaptor.go +++ b/relay/channel/mokaai/adaptor.go @@ -18,6 +18,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go index ff88de8b..1f3fda8d 100644 --- a/relay/channel/ollama/adaptor.go +++ b/relay/channel/ollama/adaptor.go @@ -17,6 +17,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { openaiAdaptor := openai.Adaptor{} openaiRequest, err := openaiAdaptor.ConvertClaudeRequest(c, info, request) diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index efd22878..df858ea2 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -34,6 +34,15 @@ type Adaptor struct { ResponseFormat string } +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + // 使用 service.GeminiToOpenAIRequest 转换请求格式 + openaiRequest, err := service.GeminiToOpenAIRequest(request, info) + if err != nil { + return nil, err + } + return a.ConvertOpenAIRequest(c, info, openaiRequest) +} + func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { //if !strings.Contains(request.Model, "claude") { // return nil, fmt.Errorf("you are using openai channel type with path /v1/messages, only claude model supported convert, but got %s", request.Model) @@ -64,7 +73,7 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { } func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { - if info.RelayFormat == relaycommon.RelayFormatClaude { + if info.RelayFormat == relaycommon.RelayFormatClaude || info.RelayFormat == relaycommon.RelayFormatGemini { return fmt.Sprintf("%s/v1/chat/completions", info.BaseUrl), nil } if info.RelayMode == relayconstant.RelayModeRealtime { diff --git a/relay/channel/openai/helper.go b/relay/channel/openai/helper.go index 1681c9ff..528f1276 100644 --- a/relay/channel/openai/helper.go +++ b/relay/channel/openai/helper.go @@ -2,6 +2,8 @@ package openai import ( "encoding/json" + "errors" + "net/http" "one-api/common" "one-api/dto" relaycommon "one-api/relay/common" @@ -16,11 +18,14 @@ import ( // 辅助函数 func HandleStreamFormat(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error { info.SendResponseCount++ + switch info.RelayFormat { case relaycommon.RelayFormatOpenAI: return sendStreamData(c, info, data, forceFormat, thinkToContent) case relaycommon.RelayFormatClaude: return handleClaudeFormat(c, data, info) + case relaycommon.RelayFormatGemini: + return handleGeminiFormat(c, data, info) } return nil } @@ -41,6 +46,46 @@ func handleClaudeFormat(c *gin.Context, data string, info *relaycommon.RelayInfo return nil } +func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo) error { + // 截取前50个字符用于调试 + debugData := data + if len(data) > 50 { + debugData = data[:50] + "..." + } + common.LogInfo(c, "handleGeminiFormat called with data: "+debugData) + + var streamResponse dto.ChatCompletionsStreamResponse + if err := common.Unmarshal(common.StringToByteSlice(data), &streamResponse); err != nil { + common.LogError(c, "failed to unmarshal stream response: "+err.Error()) + return err + } + + common.LogInfo(c, "successfully unmarshaled stream response") + geminiResponse := service.StreamResponseOpenAI2Gemini(&streamResponse, info) + + // 如果返回 nil,表示没有实际内容,跳过发送 + if geminiResponse == nil { + common.LogInfo(c, "handleGeminiFormat: no content to send, skipping") + return nil + } + + geminiResponseStr, err := common.Marshal(geminiResponse) + if err != nil { + common.LogError(c, "failed to marshal gemini response: "+err.Error()) + return err + } + + common.LogInfo(c, "sending gemini format response") + // send gemini format response + c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)}) + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + } else { + return errors.New("streaming error: flusher not found") + } + return nil +} + func ProcessStreamResponse(streamResponse dto.ChatCompletionsStreamResponse, responseTextBuilder *strings.Builder, toolCount *int) error { for _, choice := range streamResponse.Choices { responseTextBuilder.WriteString(choice.Delta.GetContentString()) @@ -185,6 +230,37 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream for _, resp := range claudeResponses { _ = helper.ClaudeData(c, *resp) } + + case relaycommon.RelayFormatGemini: + var streamResponse dto.ChatCompletionsStreamResponse + if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + return + } + + // 这里处理的是 openai 最后一个流响应,其 delta 为空,有 finish_reason 字段 + // 因此相比较于 google 官方的流响应,由 openai 转换而来会多一个 parts 为空,finishReason 为 STOP 的响应 + // 而包含最后一段文本输出的响应(倒数第二个)的 finishReason 为 null + // 暂不知是否有程序会不兼容。 + + geminiResponse := service.StreamResponseOpenAI2Gemini(&streamResponse, info) + + // openai 流响应开头的空数据 + if geminiResponse == nil { + return + } + + geminiResponseStr, err := common.Marshal(geminiResponse) + if err != nil { + common.SysError("error marshalling gemini response: " + err.Error()) + return + } + + // 发送最终的 Gemini 响应 + c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)}) + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + } } } diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index f6a04f3a..9ae0a200 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -223,6 +223,13 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo return nil, types.NewError(err, types.ErrorCodeBadResponseBody) } responseBody = claudeRespStr + case relaycommon.RelayFormatGemini: + geminiResp := service.ResponseOpenAI2Gemini(&simpleResponse, info) + geminiRespStr, err := common.Marshal(geminiResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + responseBody = geminiRespStr } common.IOCopyBytesGracefully(c, resp, responseBody) diff --git a/relay/channel/palm/adaptor.go b/relay/channel/palm/adaptor.go index a60dc4b2..4d1ab783 100644 --- a/relay/channel/palm/adaptor.go +++ b/relay/channel/palm/adaptor.go @@ -17,6 +17,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/perplexity/adaptor.go b/relay/channel/perplexity/adaptor.go index 19830aca..92cb08a2 100644 --- a/relay/channel/perplexity/adaptor.go +++ b/relay/channel/perplexity/adaptor.go @@ -17,6 +17,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/siliconflow/adaptor.go b/relay/channel/siliconflow/adaptor.go index c80e9ea1..05e6d453 100644 --- a/relay/channel/siliconflow/adaptor.go +++ b/relay/channel/siliconflow/adaptor.go @@ -18,6 +18,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { adaptor := openai.Adaptor{} return adaptor.ConvertClaudeRequest(c, info, req) diff --git a/relay/channel/tencent/adaptor.go b/relay/channel/tencent/adaptor.go index 520276a7..b86d8a16 100644 --- a/relay/channel/tencent/adaptor.go +++ b/relay/channel/tencent/adaptor.go @@ -25,6 +25,11 @@ type Adaptor struct { Timestamp int64 } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index c88b4359..39be998e 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -44,6 +44,10 @@ type Adaptor struct { AccountCredentials Credentials } +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + return request, nil +} + func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { if v, ok := claudeModelMap[info.UpstreamModelName]; ok { c.Set("request_model", v) diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go index af15d636..225b3895 100644 --- a/relay/channel/volcengine/adaptor.go +++ b/relay/channel/volcengine/adaptor.go @@ -23,6 +23,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/xai/adaptor.go b/relay/channel/xai/adaptor.go index 8d880137..6a3a5370 100644 --- a/relay/channel/xai/adaptor.go +++ b/relay/channel/xai/adaptor.go @@ -19,6 +19,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me //panic("implement me") diff --git a/relay/channel/xunfei/adaptor.go b/relay/channel/xunfei/adaptor.go index 0d218ada..7ee76f1a 100644 --- a/relay/channel/xunfei/adaptor.go +++ b/relay/channel/xunfei/adaptor.go @@ -17,6 +17,11 @@ type Adaptor struct { request *dto.GeneralOpenAIRequest } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/zhipu/adaptor.go b/relay/channel/zhipu/adaptor.go index 43344428..e3be0e8e 100644 --- a/relay/channel/zhipu/adaptor.go +++ b/relay/channel/zhipu/adaptor.go @@ -16,6 +16,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/zhipu_4v/adaptor.go b/relay/channel/zhipu_4v/adaptor.go index edd7a534..83070fe5 100644 --- a/relay/channel/zhipu_4v/adaptor.go +++ b/relay/channel/zhipu_4v/adaptor.go @@ -18,6 +18,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 43c7ca58..862630ea 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -20,8 +20,8 @@ import ( "github.com/gin-gonic/gin" ) -func getAndValidateGeminiRequest(c *gin.Context) (*gemini.GeminiChatRequest, error) { - request := &gemini.GeminiChatRequest{} +func getAndValidateGeminiRequest(c *gin.Context) (*dto.GeminiChatRequest, error) { + request := &dto.GeminiChatRequest{} err := common.UnmarshalBodyReusable(c, request) if err != nil { return nil, err @@ -44,7 +44,7 @@ func checkGeminiStreamMode(c *gin.Context, relayInfo *relaycommon.RelayInfo) { // } } -func checkGeminiInputSensitive(textRequest *gemini.GeminiChatRequest) ([]string, error) { +func checkGeminiInputSensitive(textRequest *dto.GeminiChatRequest) ([]string, error) { var inputTexts []string for _, content := range textRequest.Contents { for _, part := range content.Parts { @@ -61,7 +61,7 @@ func checkGeminiInputSensitive(textRequest *gemini.GeminiChatRequest) ([]string, return sensitiveWords, err } -func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.RelayInfo) int { +func getGeminiInputTokens(req *dto.GeminiChatRequest, info *relaycommon.RelayInfo) int { // 计算输入 token 数量 var inputTexts []string for _, content := range req.Contents { @@ -78,7 +78,7 @@ func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.Relay return inputTokens } -func isNoThinkingRequest(req *gemini.GeminiChatRequest) bool { +func isNoThinkingRequest(req *dto.GeminiChatRequest) bool { if req.GenerationConfig.ThinkingConfig != nil && req.GenerationConfig.ThinkingConfig.ThinkingBudget != nil { return *req.GenerationConfig.ThinkingConfig.ThinkingBudget == 0 } @@ -202,7 +202,12 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) { } requestBody = bytes.NewReader(body) } else { - jsonData, err := common.Marshal(req) + // 使用 ConvertGeminiRequest 转换请求格式 + convertedRequest, err := adaptor.ConvertGeminiRequest(c, relayInfo, req) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + jsonData, err := common.Marshal(convertedRequest) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } diff --git a/service/convert.go b/service/convert.go index 787cc79d..ee8ecee5 100644 --- a/service/convert.go +++ b/service/convert.go @@ -448,3 +448,353 @@ func toJSONString(v interface{}) string { } return string(b) } + +func GeminiToOpenAIRequest(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) { + openaiRequest := &dto.GeneralOpenAIRequest{ + Model: info.UpstreamModelName, + Stream: info.IsStream, + } + + // 转换 messages + var messages []dto.Message + for _, content := range geminiRequest.Contents { + message := dto.Message{ + Role: convertGeminiRoleToOpenAI(content.Role), + } + + // 处理 parts + var mediaContents []dto.MediaContent + var toolCalls []dto.ToolCallRequest + for _, part := range content.Parts { + if part.Text != "" { + mediaContent := dto.MediaContent{ + Type: "text", + Text: part.Text, + } + mediaContents = append(mediaContents, mediaContent) + } else if part.InlineData != nil { + mediaContent := dto.MediaContent{ + Type: "image_url", + ImageUrl: &dto.MessageImageUrl{ + Url: fmt.Sprintf("data:%s;base64,%s", part.InlineData.MimeType, part.InlineData.Data), + Detail: "auto", + MimeType: part.InlineData.MimeType, + }, + } + mediaContents = append(mediaContents, mediaContent) + } else if part.FileData != nil { + mediaContent := dto.MediaContent{ + Type: "image_url", + ImageUrl: &dto.MessageImageUrl{ + Url: part.FileData.FileUri, + Detail: "auto", + MimeType: part.FileData.MimeType, + }, + } + mediaContents = append(mediaContents, mediaContent) + } else if part.FunctionCall != nil { + // 处理 Gemini 的工具调用 + toolCall := dto.ToolCallRequest{ + ID: fmt.Sprintf("call_%d", len(toolCalls)+1), // 生成唯一ID + Type: "function", + Function: dto.FunctionRequest{ + Name: part.FunctionCall.FunctionName, + Arguments: toJSONString(part.FunctionCall.Arguments), + }, + } + toolCalls = append(toolCalls, toolCall) + } else if part.FunctionResponse != nil { + // 处理 Gemini 的工具响应,创建单独的 tool 消息 + toolMessage := dto.Message{ + Role: "tool", + ToolCallId: fmt.Sprintf("call_%d", len(toolCalls)), // 使用对应的调用ID + } + toolMessage.SetStringContent(toJSONString(part.FunctionResponse.Response)) + messages = append(messages, toolMessage) + } + } + + // 设置消息内容 + if len(toolCalls) > 0 { + // 如果有工具调用,设置工具调用 + message.SetToolCalls(toolCalls) + } else if len(mediaContents) == 1 && mediaContents[0].Type == "text" { + // 如果只有一个文本内容,直接设置字符串 + message.Content = mediaContents[0].Text + } else if len(mediaContents) > 0 { + // 如果有多个内容或包含媒体,设置为数组 + message.SetMediaContent(mediaContents) + } + + // 只有当消息有内容或工具调用时才添加 + if len(message.ParseContent()) > 0 || len(message.ToolCalls) > 0 { + messages = append(messages, message) + } + } + + openaiRequest.Messages = messages + + if geminiRequest.GenerationConfig.Temperature != nil { + openaiRequest.Temperature = geminiRequest.GenerationConfig.Temperature + } + if geminiRequest.GenerationConfig.TopP > 0 { + openaiRequest.TopP = geminiRequest.GenerationConfig.TopP + } + if geminiRequest.GenerationConfig.TopK > 0 { + openaiRequest.TopK = int(geminiRequest.GenerationConfig.TopK) + } + if geminiRequest.GenerationConfig.MaxOutputTokens > 0 { + openaiRequest.MaxTokens = geminiRequest.GenerationConfig.MaxOutputTokens + } + // gemini stop sequences 最多 5 个,openai stop 最多 4 个 + if len(geminiRequest.GenerationConfig.StopSequences) > 0 { + openaiRequest.Stop = geminiRequest.GenerationConfig.StopSequences[:4] + } + if geminiRequest.GenerationConfig.CandidateCount > 0 { + openaiRequest.N = geminiRequest.GenerationConfig.CandidateCount + } + + // 转换工具调用 + if len(geminiRequest.Tools) > 0 { + var tools []dto.ToolCallRequest + for _, tool := range geminiRequest.Tools { + if tool.FunctionDeclarations != nil { + // 将 Gemini 的 FunctionDeclarations 转换为 OpenAI 的 ToolCallRequest + functionDeclarations, ok := tool.FunctionDeclarations.([]dto.FunctionRequest) + if ok { + for _, function := range functionDeclarations { + openAITool := dto.ToolCallRequest{ + Type: "function", + Function: dto.FunctionRequest{ + Name: function.Name, + Description: function.Description, + Parameters: function.Parameters, + }, + } + tools = append(tools, openAITool) + } + } + } + } + if len(tools) > 0 { + openaiRequest.Tools = tools + } + } + + // gemini system instructions + if geminiRequest.SystemInstructions != nil { + // 将系统指令作为第一条消息插入 + systemMessage := dto.Message{ + Role: "system", + Content: extractTextFromGeminiParts(geminiRequest.SystemInstructions.Parts), + } + openaiRequest.Messages = append([]dto.Message{systemMessage}, openaiRequest.Messages...) + } + + return openaiRequest, nil +} + +func convertGeminiRoleToOpenAI(geminiRole string) string { + switch geminiRole { + case "user": + return "user" + case "model": + return "assistant" + case "function": + return "function" + default: + return "user" + } +} + +func extractTextFromGeminiParts(parts []dto.GeminiPart) string { + var texts []string + for _, part := range parts { + if part.Text != "" { + texts = append(texts, part.Text) + } + } + return strings.Join(texts, "\n") +} + +// ResponseOpenAI2Gemini 将 OpenAI 响应转换为 Gemini 格式 +func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse { + geminiResponse := &dto.GeminiChatResponse{ + Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)), + PromptFeedback: dto.GeminiChatPromptFeedback{ + SafetyRatings: []dto.GeminiChatSafetyRating{}, + }, + UsageMetadata: dto.GeminiUsageMetadata{ + PromptTokenCount: openAIResponse.PromptTokens, + CandidatesTokenCount: openAIResponse.CompletionTokens, + TotalTokenCount: openAIResponse.PromptTokens + openAIResponse.CompletionTokens, + }, + } + + for _, choice := range openAIResponse.Choices { + candidate := dto.GeminiChatCandidate{ + Index: int64(choice.Index), + SafetyRatings: []dto.GeminiChatSafetyRating{}, + } + + // 设置结束原因 + var finishReason string + switch choice.FinishReason { + case "stop": + finishReason = "STOP" + case "length": + finishReason = "MAX_TOKENS" + case "content_filter": + finishReason = "SAFETY" + case "tool_calls": + finishReason = "STOP" + default: + finishReason = "STOP" + } + candidate.FinishReason = &finishReason + + // 转换消息内容 + content := dto.GeminiChatContent{ + Role: "model", + Parts: make([]dto.GeminiPart, 0), + } + + // 处理工具调用 + toolCalls := choice.Message.ParseToolCalls() + if len(toolCalls) > 0 { + for _, toolCall := range toolCalls { + // 解析参数 + var args map[string]interface{} + if toolCall.Function.Arguments != "" { + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { + args = map[string]interface{}{"arguments": toolCall.Function.Arguments} + } + } else { + args = make(map[string]interface{}) + } + + part := dto.GeminiPart{ + FunctionCall: &dto.FunctionCall{ + FunctionName: toolCall.Function.Name, + Arguments: args, + }, + } + content.Parts = append(content.Parts, part) + } + } else { + // 处理文本内容 + textContent := choice.Message.StringContent() + if textContent != "" { + part := dto.GeminiPart{ + Text: textContent, + } + content.Parts = append(content.Parts, part) + } + } + + candidate.Content = content + geminiResponse.Candidates = append(geminiResponse.Candidates, candidate) + } + + return geminiResponse +} + +// StreamResponseOpenAI2Gemini 将 OpenAI 流式响应转换为 Gemini 格式 +func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse { + // 检查是否有实际内容或结束标志 + hasContent := false + hasFinishReason := false + for _, choice := range openAIResponse.Choices { + if len(choice.Delta.GetContentString()) > 0 || (choice.Delta.ToolCalls != nil && len(choice.Delta.ToolCalls) > 0) { + hasContent = true + } + if choice.FinishReason != nil { + hasFinishReason = true + } + } + + // 如果没有实际内容且没有结束标志,跳过。主要针对 openai 流响应开头的空数据 + if !hasContent && !hasFinishReason { + return nil + } + + geminiResponse := &dto.GeminiChatResponse{ + Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)), + PromptFeedback: dto.GeminiChatPromptFeedback{ + SafetyRatings: []dto.GeminiChatSafetyRating{}, + }, + UsageMetadata: dto.GeminiUsageMetadata{ + PromptTokenCount: info.PromptTokens, + CandidatesTokenCount: 0, // 流式响应中可能没有完整的 usage 信息 + TotalTokenCount: info.PromptTokens, + }, + } + + for _, choice := range openAIResponse.Choices { + candidate := dto.GeminiChatCandidate{ + Index: int64(choice.Index), + SafetyRatings: []dto.GeminiChatSafetyRating{}, + } + + // 设置结束原因 + if choice.FinishReason != nil { + var finishReason string + switch *choice.FinishReason { + case "stop": + finishReason = "STOP" + case "length": + finishReason = "MAX_TOKENS" + case "content_filter": + finishReason = "SAFETY" + case "tool_calls": + finishReason = "STOP" + default: + finishReason = "STOP" + } + candidate.FinishReason = &finishReason + } + + // 转换消息内容 + content := dto.GeminiChatContent{ + Role: "model", + Parts: make([]dto.GeminiPart, 0), + } + + // 处理工具调用 + if choice.Delta.ToolCalls != nil { + for _, toolCall := range choice.Delta.ToolCalls { + // 解析参数 + var args map[string]interface{} + if toolCall.Function.Arguments != "" { + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { + args = map[string]interface{}{"arguments": toolCall.Function.Arguments} + } + } else { + args = make(map[string]interface{}) + } + + part := dto.GeminiPart{ + FunctionCall: &dto.FunctionCall{ + FunctionName: toolCall.Function.Name, + Arguments: args, + }, + } + content.Parts = append(content.Parts, part) + } + } else { + // 处理文本内容 + textContent := choice.Delta.GetContentString() + if textContent != "" { + part := dto.GeminiPart{ + Text: textContent, + } + content.Parts = append(content.Parts, part) + } + } + + candidate.Content = content + geminiResponse.Candidates = append(geminiResponse.Candidates, candidate) + } + + return geminiResponse +} From a0c6ebe2d8aa7cab71d6a9b407abd4f9d9649888 Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Fri, 1 Aug 2025 22:29:19 +0800 Subject: [PATCH 08/17] chore: remove debug log --- relay/channel/openai/helper.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/relay/channel/openai/helper.go b/relay/channel/openai/helper.go index 528f1276..11a34ca5 100644 --- a/relay/channel/openai/helper.go +++ b/relay/channel/openai/helper.go @@ -47,25 +47,16 @@ func handleClaudeFormat(c *gin.Context, data string, info *relaycommon.RelayInfo } func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo) error { - // 截取前50个字符用于调试 - debugData := data - if len(data) > 50 { - debugData = data[:50] + "..." - } - common.LogInfo(c, "handleGeminiFormat called with data: "+debugData) - var streamResponse dto.ChatCompletionsStreamResponse if err := common.Unmarshal(common.StringToByteSlice(data), &streamResponse); err != nil { common.LogError(c, "failed to unmarshal stream response: "+err.Error()) return err } - common.LogInfo(c, "successfully unmarshaled stream response") geminiResponse := service.StreamResponseOpenAI2Gemini(&streamResponse, info) // 如果返回 nil,表示没有实际内容,跳过发送 if geminiResponse == nil { - common.LogInfo(c, "handleGeminiFormat: no content to send, skipping") return nil } @@ -75,7 +66,6 @@ func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo return err } - common.LogInfo(c, "sending gemini format response") // send gemini format response c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)}) if flusher, ok := c.Writer.(http.Flusher); ok { From ef0db0f914e0531454b0346569e622538e6b9ddc Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 2 Aug 2025 10:57:03 +0800 Subject: [PATCH 09/17] feat: implement key mode for multi-key channels with append/replace options --- controller/channel.go | 66 +++++++- .../channels/modals/EditChannelModal.jsx | 149 ++++++++++++------ 2 files changed, 168 insertions(+), 47 deletions(-) diff --git a/controller/channel.go b/controller/channel.go index d3bfa202..513e3024 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -669,6 +669,7 @@ func DeleteChannelBatch(c *gin.Context) { type PatchChannel struct { model.Channel MultiKeyMode *string `json:"multi_key_mode"` + KeyMode *string `json:"key_mode"` // 多key模式下密钥覆盖或者追加 } func UpdateChannel(c *gin.Context) { @@ -688,7 +689,7 @@ func UpdateChannel(c *gin.Context) { return } // Preserve existing ChannelInfo to ensure multi-key channels keep correct state even if the client does not send ChannelInfo in the request. - originChannel, err := model.GetChannelById(channel.Id, false) + originChannel, err := model.GetChannelById(channel.Id, true) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -704,6 +705,69 @@ func UpdateChannel(c *gin.Context) { if channel.MultiKeyMode != nil && *channel.MultiKeyMode != "" { channel.ChannelInfo.MultiKeyMode = constant.MultiKeyMode(*channel.MultiKeyMode) } + + // 处理多key模式下的密钥追加/覆盖逻辑 + if channel.KeyMode != nil && channel.ChannelInfo.IsMultiKey { + switch *channel.KeyMode { + case "append": + // 追加模式:将新密钥添加到现有密钥列表 + if originChannel.Key != "" { + var newKeys []string + var existingKeys []string + + // 解析现有密钥 + if strings.HasPrefix(strings.TrimSpace(originChannel.Key), "[") { + // JSON数组格式 + var arr []json.RawMessage + if err := json.Unmarshal([]byte(strings.TrimSpace(originChannel.Key)), &arr); err == nil { + existingKeys = make([]string, len(arr)) + for i, v := range arr { + existingKeys[i] = string(v) + } + } + } else { + // 换行分隔格式 + existingKeys = strings.Split(strings.Trim(originChannel.Key, "\n"), "\n") + } + + // 处理 Vertex AI 的特殊情况 + if channel.Type == constant.ChannelTypeVertexAi { + // 尝试解析新密钥为JSON数组 + if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") { + array, err := getVertexArrayKeys(channel.Key) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "追加密钥解析失败: " + err.Error(), + }) + return + } + newKeys = array + } else { + // 单个JSON密钥 + newKeys = []string{channel.Key} + } + // 合并密钥 + allKeys := append(existingKeys, newKeys...) + channel.Key = strings.Join(allKeys, "\n") + } else { + // 普通渠道的处理 + inputKeys := strings.Split(channel.Key, "\n") + for _, key := range inputKeys { + key = strings.TrimSpace(key) + if key != "" { + newKeys = append(newKeys, key) + } + } + // 合并密钥 + allKeys := append(existingKeys, newKeys...) + channel.Key = strings.Join(allKeys, "\n") + } + } + case "replace": + // 覆盖模式:直接使用新密钥(默认行为,不需要特殊处理) + } + } err = channel.Update() if err != nil { common.ApiError(c, err) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 37e9af75..8c8bdb70 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -154,6 +154,7 @@ const EditChannelModal = (props) => { const [isMultiKeyChannel, setIsMultiKeyChannel] = useState(false); const [channelSearchValue, setChannelSearchValue] = useState(''); const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式 + const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加) // 渠道额外设置状态 const [channelSettings, setChannelSettings] = useState({ force_format: false, @@ -560,6 +561,12 @@ const EditChannelModal = (props) => { pass_through_body_enabled: false, system_prompt: '', }); + // 重置密钥模式状态 + setKeyMode('append'); + // 清空表单中的key_mode字段 + if (formApiRef.current) { + formApiRef.current.setValue('key_mode', undefined); + } } }, [props.visible, channelId]); @@ -725,6 +732,7 @@ const EditChannelModal = (props) => { res = await API.put(`/api/channel/`, { ...localInputs, id: parseInt(channelId), + key_mode: isMultiKeyChannel ? keyMode : undefined, // 只在多key模式下传递 }); } else { res = await API.post(`/api/channel/`, { @@ -787,55 +795,59 @@ const EditChannelModal = (props) => { const batchAllowed = !isEdit || isMultiKeyChannel; const batchExtra = batchAllowed ? ( - { - const checked = e.target.checked; + {!isEdit && ( + { + const checked = e.target.checked; - if (!checked && vertexFileList.length > 1) { - Modal.confirm({ - title: t('切换为单密钥模式'), - content: t('将仅保留第一个密钥文件,其余文件将被移除,是否继续?'), - onOk: () => { - const firstFile = vertexFileList[0]; - const firstKey = vertexKeys[0] ? [vertexKeys[0]] : []; + if (!checked && vertexFileList.length > 1) { + Modal.confirm({ + title: t('切换为单密钥模式'), + content: t('将仅保留第一个密钥文件,其余文件将被移除,是否继续?'), + onOk: () => { + const firstFile = vertexFileList[0]; + const firstKey = vertexKeys[0] ? [vertexKeys[0]] : []; - setVertexFileList([firstFile]); - setVertexKeys(firstKey); + setVertexFileList([firstFile]); + setVertexKeys(firstKey); - formApiRef.current?.setValue('vertex_files', [firstFile]); - setInputs((prev) => ({ ...prev, vertex_files: [firstFile] })); + formApiRef.current?.setValue('vertex_files', [firstFile]); + setInputs((prev) => ({ ...prev, vertex_files: [firstFile] })); - setBatch(false); - setMultiToSingle(false); - setMultiKeyMode('random'); - }, - onCancel: () => { - setBatch(true); - }, - centered: true, - }); - return; - } - - setBatch(checked); - if (!checked) { - setMultiToSingle(false); - setMultiKeyMode('random'); - } else { - // 批量模式下禁用手动输入,并清空手动输入的内容 - setUseManualInput(false); - if (inputs.type === 41) { - // 清空手动输入的密钥内容 - if (formApiRef.current) { - formApiRef.current.setValue('key', ''); - } - handleInputChange('key', ''); + setBatch(false); + setMultiToSingle(false); + setMultiKeyMode('random'); + }, + onCancel: () => { + setBatch(true); + }, + centered: true, + }); + return; } - } - }} - >{t('批量创建')} + + setBatch(checked); + if (!checked) { + setMultiToSingle(false); + setMultiKeyMode('random'); + } else { + // 批量模式下禁用手动输入,并清空手动输入的内容 + setUseManualInput(false); + if (inputs.type === 41) { + // 清空手动输入的密钥内容 + if (formApiRef.current) { + formApiRef.current.setValue('key', ''); + } + handleInputChange('key', ''); + } + } + }} + > + {t('批量创建')} + + )} {batch && ( { setMultiToSingle(prev => !prev); @@ -1032,7 +1044,16 @@ const EditChannelModal = (props) => { autosize autoComplete='new-password' onChange={(value) => handleInputChange('key', value)} - extraText={batchExtra} + extraText={ +
+ {isEdit && isMultiKeyChannel && keyMode === 'append' && ( + + {t('追加模式:新密钥将添加到现有密钥列表的末尾')} + + )} + {batchExtra} +
+ } showClear /> ) @@ -1099,6 +1120,11 @@ const EditChannelModal = (props) => { {t('请输入完整的 JSON 格式密钥内容')} + {isEdit && isMultiKeyChannel && keyMode === 'append' && ( + + {t('追加模式:新密钥将添加到现有密钥列表的末尾')} + + )} {batchExtra} } @@ -1132,13 +1158,44 @@ const EditChannelModal = (props) => { rules={isEdit ? [] : [{ required: true, message: t('请输入密钥') }]} autoComplete='new-password' onChange={(value) => handleInputChange('key', value)} - extraText={batchExtra} + extraText={ +
+ {isEdit && isMultiKeyChannel && keyMode === 'append' && ( + + {t('追加模式:新密钥将添加到现有密钥列表的末尾')} + + )} + {batchExtra} +
+ } showClear /> )} )} + {isEdit && isMultiKeyChannel && ( + setKeyMode(value)} + extraText={ + + {keyMode === 'replace' + ? t('覆盖模式:将完全替换现有的所有密钥') + : t('追加模式:将新密钥添加到现有密钥列表末尾') + } + + } + /> + )} {batch && multiToSingle && ( <> Date: Sat, 2 Aug 2025 11:07:50 +0800 Subject: [PATCH 10/17] feat: add recordErrorLog option to NewAPIError for conditional error logging --- controller/relay.go | 2 +- relay/relay-text.go | 6 +++--- types/error.go | 30 ++++++++++++++++++++++++------ 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/controller/relay.go b/controller/relay.go index b5b8f8fe..1a35c7d7 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -47,7 +47,7 @@ func relayHandler(c *gin.Context, relayMode int) *types.NewAPIError { err = relay.TextHelper(c) } - if constant2.ErrorLogEnabled && err != nil { + if constant2.ErrorLogEnabled && err != nil && types.IsRecordErrorLog(err) { // 保存错误日志到mysql中 userId := c.GetInt("id") tokenName := c.GetString("token_name") diff --git a/relay/relay-text.go b/relay/relay-text.go index 97313be6..f175dbfb 100644 --- a/relay/relay-text.go +++ b/relay/relay-text.go @@ -305,10 +305,10 @@ func preConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo return 0, 0, types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry()) } if userQuota <= 0 { - return 0, 0, types.NewErrorWithStatusCode(errors.New("user quota is not enough"), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry()) + return 0, 0, types.NewErrorWithStatusCode(errors.New("user quota is not enough"), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) } if userQuota-preConsumedQuota < 0 { - return 0, 0, types.NewErrorWithStatusCode(fmt.Errorf("pre-consume quota failed, user quota: %s, need quota: %s", common.FormatQuota(userQuota), common.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry()) + return 0, 0, types.NewErrorWithStatusCode(fmt.Errorf("pre-consume quota failed, user quota: %s, need quota: %s", common.FormatQuota(userQuota), common.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) } relayInfo.UserQuota = userQuota if userQuota > 100*preConsumedQuota { @@ -332,7 +332,7 @@ func preConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo if preConsumedQuota > 0 { err := service.PreConsumeTokenQuota(relayInfo, preConsumedQuota) if err != nil { - return 0, 0, types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry()) + return 0, 0, types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) } err = model.DecreaseUserQuota(relayInfo.UserId, preConsumedQuota) if err != nil { diff --git a/types/error.go b/types/error.go index 86aaf692..e7265e21 100644 --- a/types/error.go +++ b/types/error.go @@ -76,12 +76,13 @@ const ( ) type NewAPIError struct { - Err error - RelayError any - skipRetry bool - errorType ErrorType - errorCode ErrorCode - StatusCode int + Err error + RelayError any + skipRetry bool + recordErrorLog *bool + errorType ErrorType + errorCode ErrorCode + StatusCode int } func (e *NewAPIError) GetErrorCode() ErrorCode { @@ -278,3 +279,20 @@ func ErrOptionWithSkipRetry() NewAPIErrorOptions { e.skipRetry = true } } + +func ErrOptionWithNoRecordErrorLog() NewAPIErrorOptions { + return func(e *NewAPIError) { + e.recordErrorLog = common.GetPointer(false) + } +} + +func IsRecordErrorLog(e *NewAPIError) bool { + if e == nil { + return false + } + if e.recordErrorLog == nil { + // default to true if not set + return true + } + return *e.recordErrorLog +} From 97d6f10f15add4e8ad564006d749235fb0c7f99e Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 2 Aug 2025 12:53:58 +0800 Subject: [PATCH 11/17] feat: enhance ConvertGeminiRequest to set default role and handle YouTube video MIME type --- relay/channel/gemini/adaptor.go | 16 ++++++++++++++++ relay/channel/vertex/adaptor.go | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index 20d43020..14fd278d 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -21,6 +21,22 @@ type Adaptor struct { } func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + if len(request.Contents) > 0 { + for i, content := range request.Contents { + if i == 0 { + if request.Contents[0].Role == "" { + request.Contents[0].Role = "user" + } + } + for _, part := range content.Parts { + if part.FileData != nil { + if part.FileData.MimeType == "" && strings.Contains(part.FileData.FileUri, "www.youtube.com") { + part.FileData.MimeType = "video/webm" + } + } + } + } + } return request, nil } diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index 39be998e..9b62cffc 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -45,7 +45,8 @@ type Adaptor struct { } func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { - return request, nil + geminiAdaptor := gemini.Adaptor{} + return geminiAdaptor.ConvertGeminiRequest(c, info, request) } func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { From 78f34a82451fd2bb5867611ce447933af0572d13 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 2 Aug 2025 13:04:48 +0800 Subject: [PATCH 12/17] feat: retain polling index for multi-key channels during sync --- model/channel_cache.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/model/channel_cache.go b/model/channel_cache.go index 1abc8b85..98522f70 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -5,6 +5,7 @@ import ( "fmt" "math/rand" "one-api/common" + "one-api/constant" "one-api/setting" "sort" "strings" @@ -66,6 +67,15 @@ func InitChannelCache() { channelSyncLock.Lock() group2model2channels = newGroup2model2channels + //channelsIDM = newChannelId2channel + for i, channel := range newChannelId2channel { + if oldChannel, ok := channelsIDM[i]; ok { + // 存在旧的渠道,如果是多key且轮询,保留轮询索引信息 + if oldChannel.ChannelInfo.IsMultiKey && oldChannel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling { + channel.ChannelInfo.MultiKeyPollingIndex = oldChannel.ChannelInfo.MultiKeyPollingIndex + } + } + } channelsIDM = newChannelId2channel channelSyncLock.Unlock() common.SysLog("channels synced from database") @@ -203,9 +213,6 @@ func CacheGetChannel(id int) (*Channel, error) { if !ok { return nil, fmt.Errorf("渠道# %d,已不存在", id) } - if c.Status != common.ChannelStatusEnabled { - return nil, fmt.Errorf("渠道# %d,已被禁用", id) - } return c, nil } @@ -224,9 +231,6 @@ func CacheGetChannelInfo(id int) (*ChannelInfo, error) { if !ok { return nil, fmt.Errorf("渠道# %d,已不存在", id) } - if c.Status != common.ChannelStatusEnabled { - return nil, fmt.Errorf("渠道# %d,已被禁用", id) - } return &c.ChannelInfo, nil } From c28add55db23a9598a74003ca7318da3b9232dea Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 2 Aug 2025 13:16:30 +0800 Subject: [PATCH 13/17] feat: add caching for keys in channel structure and retain polling index during sync --- model/channel.go | 6 ++++++ model/channel_cache.go | 13 +++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/model/channel.go b/model/channel.go index 58f0a064..bcffc102 100644 --- a/model/channel.go +++ b/model/channel.go @@ -46,6 +46,9 @@ type Channel struct { ParamOverride *string `json:"param_override" gorm:"type:text"` // add after v0.8.5 ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"` + + // cache info + Keys []string `json:"-" gorm:"-"` } type ChannelInfo struct { @@ -71,6 +74,9 @@ func (channel *Channel) getKeys() []string { if channel.Key == "" { return []string{} } + if len(channel.Keys) > 0 { + return channel.Keys + } trimmed := strings.TrimSpace(channel.Key) // If the key starts with '[', try to parse it as a JSON array (e.g., for Vertex AI scenarios) if strings.HasPrefix(trimmed, "[") { diff --git a/model/channel_cache.go b/model/channel_cache.go index 98522f70..ecd87607 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -69,10 +69,15 @@ func InitChannelCache() { group2model2channels = newGroup2model2channels //channelsIDM = newChannelId2channel for i, channel := range newChannelId2channel { - if oldChannel, ok := channelsIDM[i]; ok { - // 存在旧的渠道,如果是多key且轮询,保留轮询索引信息 - if oldChannel.ChannelInfo.IsMultiKey && oldChannel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling { - channel.ChannelInfo.MultiKeyPollingIndex = oldChannel.ChannelInfo.MultiKeyPollingIndex + if channel.ChannelInfo.IsMultiKey { + channel.Keys = channel.getKeys() + if channel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling { + if oldChannel, ok := channelsIDM[i]; ok { + // 存在旧的渠道,如果是多key且轮询,保留轮询索引信息 + if oldChannel.ChannelInfo.IsMultiKey && oldChannel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling { + channel.ChannelInfo.MultiKeyPollingIndex = oldChannel.ChannelInfo.MultiKeyPollingIndex + } + } } } } From 7188749cb31bfac67e7189a4fffe5c14239e3536 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 2 Aug 2025 13:39:53 +0800 Subject: [PATCH 14/17] feat: truncate abilities table before processing channels --- model/ability.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/model/ability.go b/model/ability.go index 6dd8d8a6..08519de0 100644 --- a/model/ability.go +++ b/model/ability.go @@ -284,9 +284,24 @@ func FixAbility() (int, int, error) { return 0, 0, errors.New("已经有一个修复任务在运行中,请稍后再试") } defer fixLock.Unlock() + + // truncate abilities table + if common.UsingSQLite { + err := DB.Exec("DELETE FROM abilities").Error + if err != nil { + common.SysError(fmt.Sprintf("Delete abilities failed: %s", err.Error())) + return 0, 0, err + } + } else { + err := DB.Exec("TRUNCATE TABLE abilities").Error + if err != nil { + common.SysError(fmt.Sprintf("Truncate abilities failed: %s", err.Error())) + return 0, 0, err + } + } var channels []*Channel // Find all channels - err := DB.Model(&Channel{}).Find(&channels).Error + err = DB.Model(&Channel{}).Find(&channels).Error if err != nil { return 0, 0, err } From 74ec34da674470c5dc657b15816829da570eb2b6 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 2 Aug 2025 14:06:12 +0800 Subject: [PATCH 15/17] fix: improve error handling and readability in ability.go --- model/ability.go | 2 +- relay/gemini_handler.go | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/model/ability.go b/model/ability.go index 08519de0..2df45917 100644 --- a/model/ability.go +++ b/model/ability.go @@ -301,7 +301,7 @@ func FixAbility() (int, int, error) { } var channels []*Channel // Find all channels - err = DB.Model(&Channel{}).Find(&channels).Error + err := DB.Model(&Channel{}).Find(&channels).Error if err != nil { return 0, 0, err } diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 862630ea..42b695b7 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -80,7 +80,11 @@ func getGeminiInputTokens(req *dto.GeminiChatRequest, info *relaycommon.RelayInf func isNoThinkingRequest(req *dto.GeminiChatRequest) bool { if req.GenerationConfig.ThinkingConfig != nil && req.GenerationConfig.ThinkingConfig.ThinkingBudget != nil { - return *req.GenerationConfig.ThinkingConfig.ThinkingBudget == 0 + configBudget := req.GenerationConfig.ThinkingConfig.ThinkingBudget + if configBudget != nil && *configBudget == 0 { + // 如果思考预算为 0,则认为是非思考请求 + return true + } } return false } From 71e9290142dad7900cf8616ddb47ba57bf069fce Mon Sep 17 00:00:00 2001 From: Nekohy Date: Sat, 2 Aug 2025 14:19:32 +0800 Subject: [PATCH 16/17] fix: correct Gemini channel model retrieval logic --- controller/channel.go | 70 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 9 deletions(-) diff --git a/controller/channel.go b/controller/channel.go index d3bfa202..dcf9de85 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -36,11 +36,30 @@ type OpenAIModel struct { Parent string `json:"parent"` } +type GoogleOpenAICompatibleModels []struct { + Name string `json:"name"` + Version string `json:"version"` + DisplayName string `json:"displayName"` + Description string `json:"description,omitempty"` + InputTokenLimit int `json:"inputTokenLimit"` + OutputTokenLimit int `json:"outputTokenLimit"` + SupportedGenerationMethods []string `json:"supportedGenerationMethods"` + Temperature float64 `json:"temperature,omitempty"` + TopP float64 `json:"topP,omitempty"` + TopK int `json:"topK,omitempty"` + MaxTemperature int `json:"maxTemperature,omitempty"` +} + type OpenAIModelsResponse struct { Data []OpenAIModel `json:"data"` Success bool `json:"success"` } +type GoogleOpenAICompatibleResponse struct { + Models []GoogleOpenAICompatibleModels `json:"models"` + NextPageToken string `json:"nextPageToken"` +} + func parseStatusFilter(statusParam string) int { switch strings.ToLower(statusParam) { case "enabled", "1": @@ -168,26 +187,59 @@ func FetchUpstreamModels(c *gin.Context) { if channel.GetBaseURL() != "" { baseURL = channel.GetBaseURL() } - url := fmt.Sprintf("%s/v1/models", baseURL) + + var url string switch channel.Type { case constant.ChannelTypeGemini: - url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) + // curl https://example.com/v1beta/models?key=$GEMINI_API_KEY + url = fmt.Sprintf("%s/v1beta/openai/models?key=%s", baseURL, channel.Key) case constant.ChannelTypeAli: url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL) + default: + url = fmt.Sprintf("%s/v1/models", baseURL) + } + + // 获取响应体 - 根据渠道类型决定是否添加 AuthHeader + var body []byte + if channel.Type == constant.ChannelTypeGemini { + body, err = GetResponseBody("GET", url, channel, nil) // I don't know why, but Gemini requires no AuthHeader + } else { + body, err = GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) } - body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) if err != nil { common.ApiError(c, err) return } var result OpenAIModelsResponse - if err = json.Unmarshal(body, &result); err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": fmt.Sprintf("解析响应失败: %s", err.Error()), - }) - return + var parseSuccess bool + + // 适配特殊格式 + switch channel.Type { + case constant.ChannelTypeGemini: + var googleResult GoogleOpenAICompatibleResponse + if err = json.Unmarshal(body, &googleResult); err == nil { + // 转换Google格式到OpenAI格式 + for _, model := range googleResult.Models { + for _, gModel := range model { + result.Data = append(result.Data, OpenAIModel{ + ID: gModel.Name, + }) + } + } + parseSuccess = true + } + } + + // 如果解析失败,尝试OpenAI格式 + if !parseSuccess { + if err = json.Unmarshal(body, &result); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": fmt.Sprintf("解析响应失败: %s", err.Error()), + }) + return + } } var ids []string From c056a7ad7c41fe45d9d746e884bb81180785e769 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 2 Aug 2025 22:12:15 +0800 Subject: [PATCH 17/17] feat: add support for multi-key channels in RelayInfo and access token caching --- relay/channel/vertex/service_account.go | 7 ++++++- relay/common/relay_info.go | 27 +++++++++++++++---------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/relay/channel/vertex/service_account.go b/relay/channel/vertex/service_account.go index 5a97c021..9a4650d9 100644 --- a/relay/channel/vertex/service_account.go +++ b/relay/channel/vertex/service_account.go @@ -36,7 +36,12 @@ var Cache = asynccache.NewAsyncCache(asynccache.Options{ }) func getAccessToken(a *Adaptor, info *relaycommon.RelayInfo) (string, error) { - cacheKey := fmt.Sprintf("access-token-%d", info.ChannelId) + var cacheKey string + if info.ChannelIsMultiKey { + cacheKey = fmt.Sprintf("access-token-%d-%d", info.ChannelId, info.ChannelMultiKeyIndex) + } else { + cacheKey = fmt.Sprintf("access-token-%d", info.ChannelId) + } val, err := Cache.Get(cacheKey) if err == nil { return val.(string), nil diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 27827d97..266486c4 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -60,17 +60,19 @@ type ResponsesUsageInfo struct { } type RelayInfo struct { - ChannelType int - ChannelId int - TokenId int - TokenKey string - UserId int - UsingGroup string // 使用的分组 - UserGroup string // 用户所在分组 - TokenUnlimited bool - StartTime time.Time - FirstResponseTime time.Time - isFirstResponse bool + ChannelType int + ChannelId int + ChannelIsMultiKey bool // 是否多密钥 + ChannelMultiKeyIndex int // 多密钥索引 + TokenId int + TokenKey string + UserId int + UsingGroup string // 使用的分组 + UserGroup string // 用户所在分组 + TokenUnlimited bool + StartTime time.Time + FirstResponseTime time.Time + isFirstResponse bool //SendLastReasoningResponse bool ApiType int IsStream bool @@ -260,6 +262,9 @@ func GenRelayInfo(c *gin.Context) *RelayInfo { IsFirstThinkingContent: true, SendLastThinkingContent: false, }, + + ChannelIsMultiKey: common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey), + ChannelMultiKeyIndex: common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex), } if strings.HasPrefix(c.Request.URL.Path, "/pg") { info.IsPlayground = true