From f5a1cd34634b92ea2013cfa16b599d06a4253386 Mon Sep 17 00:00:00 2001 From: JustSong Date: Sat, 23 Sep 2023 22:37:11 +0800 Subject: [PATCH 01/17] feat: add support for gpt-3.5-turbo-instruct (close #545) --- common/model-ratio.go | 1 + controller/model.go | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/common/model-ratio.go b/common/model-ratio.go index eeb23e07..0d341b02 100644 --- a/common/model-ratio.go +++ b/common/model-ratio.go @@ -24,6 +24,7 @@ var ModelRatio = map[string]float64{ "gpt-3.5-turbo-0613": 0.75, "gpt-3.5-turbo-16k": 1.5, // $0.003 / 1K tokens "gpt-3.5-turbo-16k-0613": 1.5, + "gpt-3.5-turbo-instruct": 0.75, // $0.0015 / 1K tokens "text-ada-001": 0.2, "text-babbage-001": 0.25, "text-curie-001": 1, diff --git a/controller/model.go b/controller/model.go index 637ebe10..dedd0f0a 100644 --- a/controller/model.go +++ b/controller/model.go @@ -117,6 +117,15 @@ func init() { Root: "gpt-3.5-turbo-16k-0613", Parent: nil, }, + { + Id: "gpt-3.5-turbo-instruct", + Object: "model", + Created: 1677649963, + OwnedBy: "openai", + Permission: permission, + Root: "gpt-3.5-turbo-instruct", + Parent: nil, + }, { Id: "gpt-4", Object: "model", From fd9846361129452fe5ad8b45b9b401311f5872c9 Mon Sep 17 00:00:00 2001 From: JustSong Date: Sat, 23 Sep 2023 22:57:59 +0800 Subject: [PATCH 02/17] chore: update ali's model name --- common/model-ratio.go | 4 ++-- controller/model.go | 8 ++++---- web/src/pages/Channel/EditChannel.js | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/common/model-ratio.go b/common/model-ratio.go index 0d341b02..4b3dd763 100644 --- a/common/model-ratio.go +++ b/common/model-ratio.go @@ -51,8 +51,8 @@ var ModelRatio = map[string]float64{ "chatglm_pro": 0.7143, // ¥0.01 / 1k tokens "chatglm_std": 0.3572, // ¥0.005 / 1k tokens "chatglm_lite": 0.1429, // ¥0.002 / 1k tokens - "qwen-v1": 0.8572, // ¥0.012 / 1k tokens - "qwen-plus-v1": 1, // ¥0.014 / 1k tokens + "qwen-turbo": 0.8572, // ¥0.012 / 1k tokens + "qwen-plus": 10, // ¥0.14 / 1k tokens "text-embedding-v1": 0.05, // ¥0.0007 / 1k tokens "SparkDesk": 1.2858, // ¥0.018 / 1k tokens "360GPT_S2_V9": 0.8572, // ¥0.012 / 1k tokens diff --git a/controller/model.go b/controller/model.go index dedd0f0a..ae2061b3 100644 --- a/controller/model.go +++ b/controller/model.go @@ -352,21 +352,21 @@ func init() { Parent: nil, }, { - Id: "qwen-v1", + Id: "qwen-turbo", Object: "model", Created: 1677649963, OwnedBy: "ali", Permission: permission, - Root: "qwen-v1", + Root: "qwen-turbo", Parent: nil, }, { - Id: "qwen-plus-v1", + Id: "qwen-plus", Object: "model", Created: 1677649963, OwnedBy: "ali", Permission: permission, - Root: "qwen-plus-v1", + Root: "qwen-plus", Parent: nil, }, { diff --git a/web/src/pages/Channel/EditChannel.js b/web/src/pages/Channel/EditChannel.js index 4c8dd0c4..9b128591 100644 --- a/web/src/pages/Channel/EditChannel.js +++ b/web/src/pages/Channel/EditChannel.js @@ -67,7 +67,7 @@ const EditChannel = () => { localModels = ['ERNIE-Bot', 'ERNIE-Bot-turbo', 'Embedding-V1']; break; case 17: - localModels = ['qwen-v1', 'qwen-plus-v1', 'text-embedding-v1']; + localModels = ['qwen-turbo', 'qwen-plus', 'text-embedding-v1']; break; case 16: localModels = ['chatglm_pro', 'chatglm_std', 'chatglm_lite']; From f9b748c2caf02879f0ff9c104701e12d71894bff Mon Sep 17 00:00:00 2001 From: JustSong Date: Fri, 29 Sep 2023 11:38:27 +0800 Subject: [PATCH 03/17] chore: add MEMORY_CACHE_ENABLED env variable --- README.md | 20 +++++++++++--------- common/constants.go | 3 ++- main.go | 20 ++++++++++---------- middleware/auth.go | 2 +- model/cache.go | 2 +- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 7f85bcc0..70ff4c15 100644 --- a/README.md +++ b/README.md @@ -309,22 +309,24 @@ graph LR + `SQL_CONN_MAX_LIFETIME`:连接的最大生命周期,默认为 `60`,单位分钟。 4. `FRONTEND_BASE_URL`:设置之后将重定向页面请求到指定的地址,仅限从服务器设置。 + 例子:`FRONTEND_BASE_URL=https://openai.justsong.cn` -5. `SYNC_FREQUENCY`:设置之后将定期与数据库同步配置,单位为秒,未设置则不进行同步。 +5. `MEMORY_CACHE_ENABLED`:启用内存缓存,会导致用户额度的更新存在一定的延迟,可选值为 `true` 和 `false`,未设置则默认为 `false`。 + + 例子:`MEMORY_CACHE_ENABLED=true` +6. `SYNC_FREQUENCY`:在启用缓存的情况下与数据库同步配置的频率,单位为秒,默认为 `600` 秒。 + 例子:`SYNC_FREQUENCY=60` -6. `NODE_TYPE`:设置之后将指定节点类型,可选值为 `master` 和 `slave`,未设置则默认为 `master`。 +7. `NODE_TYPE`:设置之后将指定节点类型,可选值为 `master` 和 `slave`,未设置则默认为 `master`。 + 例子:`NODE_TYPE=slave` -7. `CHANNEL_UPDATE_FREQUENCY`:设置之后将定期更新渠道余额,单位为分钟,未设置则不进行更新。 +8. `CHANNEL_UPDATE_FREQUENCY`:设置之后将定期更新渠道余额,单位为分钟,未设置则不进行更新。 + 例子:`CHANNEL_UPDATE_FREQUENCY=1440` -8. `CHANNEL_TEST_FREQUENCY`:设置之后将定期检查渠道,单位为分钟,未设置则不进行检查。 +9. `CHANNEL_TEST_FREQUENCY`:设置之后将定期检查渠道,单位为分钟,未设置则不进行检查。 + 例子:`CHANNEL_TEST_FREQUENCY=1440` -9. `POLLING_INTERVAL`:批量更新渠道余额以及测试可用性时的请求间隔,单位为秒,默认无间隔。 - + 例子:`POLLING_INTERVAL=5` -10. `BATCH_UPDATE_ENABLED`:启用数据库批量更新聚合,会导致用户额度的更新存在一定的延迟可选值为 `true` 和 `false`,未设置则默认为 `false`。 +10. `POLLING_INTERVAL`:批量更新渠道余额以及测试可用性时的请求间隔,单位为秒,默认无间隔。 + + 例子:`POLLING_INTERVAL=5` +11. `BATCH_UPDATE_ENABLED`:启用数据库批量更新聚合,会导致用户额度的更新存在一定的延迟可选值为 `true` 和 `false`,未设置则默认为 `false`。 + 例子:`BATCH_UPDATE_ENABLED=true` + 如果你遇到了数据库连接数过多的问题,可以尝试启用该选项。 -11. `BATCH_UPDATE_INTERVAL=5`:批量更新聚合的时间间隔,单位为秒,默认为 `5`。 +12. `BATCH_UPDATE_INTERVAL=5`:批量更新聚合的时间间隔,单位为秒,默认为 `5`。 + 例子:`BATCH_UPDATE_INTERVAL=5` -12. 请求频率限制: +13. 请求频率限制: + `GLOBAL_API_RATE_LIMIT`:全局 API 速率限制(除中继请求外),单 ip 三分钟内的最大请求数,默认为 `180`。 + `GLOBAL_WEB_RATE_LIMIT`:全局 Web 速率限制,单 ip 三分钟内的最大请求数,默认为 `60`。 diff --git a/common/constants.go b/common/constants.go index 794a795f..fe412e1a 100644 --- a/common/constants.go +++ b/common/constants.go @@ -56,6 +56,7 @@ var EmailDomainWhitelist = []string{ } var DebugEnabled = os.Getenv("DEBUG") == "true" +var MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true" var LogConsumeEnabled = true @@ -92,7 +93,7 @@ var IsMasterNode = os.Getenv("NODE_TYPE") != "slave" var requestInterval, _ = strconv.Atoi(os.Getenv("POLLING_INTERVAL")) var RequestInterval = time.Duration(requestInterval) * time.Second -var SyncFrequency = 10 * 60 // unit is second, will be overwritten by SYNC_FREQUENCY +var SyncFrequency = GetOrDefault("SYNC_FREQUENCY", 10*60) // unit is second var BatchUpdateEnabled = false var BatchUpdateInterval = GetOrDefault("BATCH_UPDATE_INTERVAL", 5) diff --git a/main.go b/main.go index e8ef4c20..88938516 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "embed" + "fmt" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" @@ -50,18 +51,17 @@ func main() { // Initialize options model.InitOptionMap() if common.RedisEnabled { + // for compatibility with old versions + common.MemoryCacheEnabled = true + } + if common.MemoryCacheEnabled { + common.SysLog("memory cache enabled") + common.SysError(fmt.Sprintf("sync frequency: %d seconds", common.SyncFrequency)) model.InitChannelCache() } - if os.Getenv("SYNC_FREQUENCY") != "" { - frequency, err := strconv.Atoi(os.Getenv("SYNC_FREQUENCY")) - if err != nil { - common.FatalLog("failed to parse SYNC_FREQUENCY: " + err.Error()) - } - common.SyncFrequency = frequency - go model.SyncOptions(frequency) - if common.RedisEnabled { - go model.SyncChannelCache(frequency) - } + if common.MemoryCacheEnabled { + go model.SyncOptions(common.SyncFrequency) + go model.SyncChannelCache(common.SyncFrequency) } if os.Getenv("CHANNEL_UPDATE_FREQUENCY") != "" { frequency, err := strconv.Atoi(os.Getenv("CHANNEL_UPDATE_FREQUENCY")) diff --git a/middleware/auth.go b/middleware/auth.go index dfbc7dbd..b0803612 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -94,7 +94,7 @@ func TokenAuth() func(c *gin.Context) { abortWithMessage(c, http.StatusUnauthorized, err.Error()) return } - userEnabled, err := model.IsUserEnabled(token.UserId) + userEnabled, err := model.CacheIsUserEnabled(token.UserId) if err != nil { abortWithMessage(c, http.StatusInternalServerError, err.Error()) return diff --git a/model/cache.go b/model/cache.go index b9d6b612..a7f5c06f 100644 --- a/model/cache.go +++ b/model/cache.go @@ -186,7 +186,7 @@ func SyncChannelCache(frequency int) { } func CacheGetRandomSatisfiedChannel(group string, model string) (*Channel, error) { - if !common.RedisEnabled { + if !common.MemoryCacheEnabled { return GetRandomSatisfiedChannel(group, model) } channelSyncLock.RLock() From 197d1d7a9d45393f2acbacf809b8357020db9a3a Mon Sep 17 00:00:00 2001 From: JustSong Date: Fri, 29 Sep 2023 17:49:47 +0800 Subject: [PATCH 04/17] docs: update readme --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 70ff4c15..49766d8b 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,9 @@ _✨ 通过标准的 OpenAI API 格式访问所有的大模型,开箱即用 > **Warning** > 使用 Docker 拉取的最新镜像可能是 `alpha` 版本,如果追求稳定性请手动指定版本。 +> **Warning** +> 使用 root 用户初次登录系统后,务必修改默认密码 `123456`! + ## 功能 1. 支持多种大模型: + [x] [OpenAI ChatGPT 系列模型](https://platform.openai.com/docs/guides/gpt/chat-completions-api)(支持 [Azure OpenAI API](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference)) From 594f06e7b00df640630132acb2dc6a659597a9af Mon Sep 17 00:00:00 2001 From: JustSong Date: Fri, 29 Sep 2023 17:56:11 +0800 Subject: [PATCH 05/17] perf: lazy initialization for token encoders (close #566) --- controller/relay-utils.go | 41 ++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/controller/relay-utils.go b/controller/relay-utils.go index 3d5948fc..4775ec88 100644 --- a/controller/relay-utils.go +++ b/controller/relay-utils.go @@ -9,44 +9,53 @@ import ( "net/http" "one-api/common" "strconv" + "strings" ) var stopFinishReason = "stop" +// tokenEncoderMap won't grow after initialization var tokenEncoderMap = map[string]*tiktoken.Tiktoken{} +var defaultTokenEncoder *tiktoken.Tiktoken func InitTokenEncoders() { common.SysLog("initializing token encoders") - fallbackTokenEncoder, err := tiktoken.EncodingForModel("gpt-3.5-turbo") + gpt35TokenEncoder, err := tiktoken.EncodingForModel("gpt-3.5-turbo") if err != nil { - common.FatalLog(fmt.Sprintf("failed to get fallback token encoder: %s", err.Error())) + common.FatalLog(fmt.Sprintf("failed to get gpt-3.5-turbo token encoder: %s", err.Error())) + } + defaultTokenEncoder = gpt35TokenEncoder + gpt4TokenEncoder, err := tiktoken.EncodingForModel("gpt-4") + if err != nil { + common.FatalLog(fmt.Sprintf("failed to get gpt-4 token encoder: %s", err.Error())) } for model, _ := range common.ModelRatio { - tokenEncoder, err := tiktoken.EncodingForModel(model) - if err != nil { - common.SysError(fmt.Sprintf("using fallback encoder for model %s", model)) - tokenEncoderMap[model] = fallbackTokenEncoder - continue + if strings.HasPrefix(model, "gpt-3.5") { + tokenEncoderMap[model] = gpt35TokenEncoder + } else if strings.HasPrefix(model, "gpt-4") { + tokenEncoderMap[model] = gpt4TokenEncoder + } else { + tokenEncoderMap[model] = nil } - tokenEncoderMap[model] = tokenEncoder } common.SysLog("token encoders initialized") } func getTokenEncoder(model string) *tiktoken.Tiktoken { - if tokenEncoder, ok := tokenEncoderMap[model]; ok { + tokenEncoder, ok := tokenEncoderMap[model] + if ok && tokenEncoder != nil { return tokenEncoder } - tokenEncoder, err := tiktoken.EncodingForModel(model) - if err != nil { - common.SysError(fmt.Sprintf("failed to get token encoder for model %s: %s, using encoder for gpt-3.5-turbo", model, err.Error())) - tokenEncoder, err = tiktoken.EncodingForModel("gpt-3.5-turbo") + if ok { + tokenEncoder, err := tiktoken.EncodingForModel(model) if err != nil { - common.FatalLog(fmt.Sprintf("failed to get token encoder for model gpt-3.5-turbo: %s", err.Error())) + common.SysError(fmt.Sprintf("failed to get token encoder for model %s: %s, using encoder for gpt-3.5-turbo", model, err.Error())) + tokenEncoder = defaultTokenEncoder } + tokenEncoderMap[model] = tokenEncoder + return tokenEncoder } - tokenEncoderMap[model] = tokenEncoder - return tokenEncoder + return defaultTokenEncoder } func getTokenNum(tokenEncoder *tiktoken.Tiktoken, text string) int { From f0fc991b447b7061d97737ee758837d21f54d0e0 Mon Sep 17 00:00:00 2001 From: JustSong Date: Fri, 29 Sep 2023 18:07:20 +0800 Subject: [PATCH 06/17] chore: remind user to change default password (close #516) --- web/src/components/LoginForm.js | 16 +++++++++++----- web/src/pages/User/EditUser.js | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/web/src/components/LoginForm.js b/web/src/components/LoginForm.js index b5c4e6f9..a3913220 100644 --- a/web/src/components/LoginForm.js +++ b/web/src/components/LoginForm.js @@ -2,8 +2,8 @@ import React, { useContext, useEffect, useState } from 'react'; import { Button, Divider, Form, Grid, Header, Image, Message, Modal, Segment } from 'semantic-ui-react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { UserContext } from '../context/User'; -import { API, getLogo, showError, showSuccess } from '../helpers'; -import { getOAuthState, onGitHubOAuthClicked } from './utils'; +import { API, getLogo, showError, showSuccess, showWarning } from '../helpers'; +import { onGitHubOAuthClicked } from './utils'; const LoginForm = () => { const [inputs, setInputs] = useState({ @@ -68,8 +68,14 @@ const LoginForm = () => { if (success) { userDispatch({ type: 'login', payload: data }); localStorage.setItem('user', JSON.stringify(data)); - navigate('/'); - showSuccess('登录成功!'); + if (username === 'root' && password === '123456') { + navigate('/user/edit'); + showSuccess('登录成功!'); + showWarning('请立刻修改默认密码!'); + } else { + navigate('/token'); + showSuccess('登录成功!'); + } } else { showError(message); } @@ -126,7 +132,7 @@ const LoginForm = () => { circular color='black' icon='github' - onClick={()=>onGitHubOAuthClicked(status.github_client_id)} + onClick={() => onGitHubOAuthClicked(status.github_client_id)} /> ) : ( <> diff --git a/web/src/pages/User/EditUser.js b/web/src/pages/User/EditUser.js index e8f96027..8ae0e556 100644 --- a/web/src/pages/User/EditUser.js +++ b/web/src/pages/User/EditUser.js @@ -102,7 +102,7 @@ const EditUser = () => { label='密码' name='password' type={'password'} - placeholder={'请输入新的密码'} + placeholder={'请输入新的密码,最短 8 位'} onChange={handleInputChange} value={password} autoComplete='new-password' From 53b2cace0b5ea70fc5bbcc05d1d5cc5b9d653c00 Mon Sep 17 00:00:00 2001 From: JustSong Date: Fri, 29 Sep 2023 18:13:57 +0800 Subject: [PATCH 07/17] chore: sync model schema --- model/channel.go | 2 +- model/log.go | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/model/channel.go b/model/channel.go index aa3b8a10..61fe9093 100644 --- a/model/channel.go +++ b/model/channel.go @@ -11,7 +11,7 @@ type Channel struct { Key string `json:"key" gorm:"not null;index"` Status int `json:"status" gorm:"default:1"` Name string `json:"name" gorm:"index"` - Weight int `json:"weight"` + Weight *uint `json:"weight" gorm:"default:0"` CreatedTime int64 `json:"created_time" gorm:"bigint"` TestTime int64 `json:"test_time" gorm:"bigint"` ResponseTime int `json:"response_time"` // in milliseconds diff --git a/model/log.go b/model/log.go index 8e177258..c189e01d 100644 --- a/model/log.go +++ b/model/log.go @@ -9,7 +9,7 @@ import ( type Log struct { Id int `json:"id"` - UserId int `json:"user_id"` + UserId int `json:"user_id" gorm:"index"` CreatedAt int64 `json:"created_at" gorm:"bigint;index"` Type int `json:"type" gorm:"index"` Content string `json:"content"` @@ -19,7 +19,7 @@ type Log struct { Quota int `json:"quota" gorm:"default:0"` PromptTokens int `json:"prompt_tokens" gorm:"default:0"` CompletionTokens int `json:"completion_tokens" gorm:"default:0"` - Channel int `json:"channel" gorm:"default:0"` + ChannelId int `json:"channel" gorm:"index"` } const ( @@ -47,7 +47,6 @@ func RecordLog(userId int, logType int, content string) { } } - func RecordConsumeLog(ctx context.Context, userId int, channelId int, promptTokens int, completionTokens int, modelName string, tokenName string, quota int, content string) { common.LogInfo(ctx, fmt.Sprintf("record consume log: userId=%d, channelId=%d, promptTokens=%d, completionTokens=%d, modelName=%s, tokenName=%s, quota=%d, content=%s", userId, channelId, promptTokens, completionTokens, modelName, tokenName, quota, content)) if !common.LogConsumeEnabled { @@ -64,7 +63,7 @@ func RecordConsumeLog(ctx context.Context, userId int, channelId int, promptToke TokenName: tokenName, ModelName: modelName, Quota: quota, - Channel: channelId, + ChannelId: channelId, } err := DB.Create(log).Error if err != nil { From 47c08c72ce21878b2f5c0bc13d86a77a75c706ee Mon Sep 17 00:00:00 2001 From: JustSong Date: Sun, 1 Oct 2023 12:49:40 +0800 Subject: [PATCH 08/17] fix: check user quota when pre-consume quota --- controller/relay-audio.go | 4 ++++ controller/relay-image.go | 2 +- controller/relay-text.go | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/controller/relay-audio.go b/controller/relay-audio.go index e6f54f01..381c6feb 100644 --- a/controller/relay-audio.go +++ b/controller/relay-audio.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -31,6 +32,9 @@ func relayAudioHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode if err != nil { return errorWrapper(err, "get_user_quota_failed", http.StatusInternalServerError) } + if userQuota-preConsumedQuota < 0 { + return errorWrapper(errors.New("user quota is not enough"), "insufficient_user_quota", http.StatusForbidden) + } err = model.CacheDecreaseUserQuota(userId, preConsumedQuota) if err != nil { return errorWrapper(err, "decrease_user_quota_failed", http.StatusInternalServerError) diff --git a/controller/relay-image.go b/controller/relay-image.go index fb30895c..998a7851 100644 --- a/controller/relay-image.go +++ b/controller/relay-image.go @@ -99,7 +99,7 @@ func relayImageHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode quota := int(ratio*sizeRatio*1000) * imageRequest.N if consumeQuota && userQuota-quota < 0 { - return errorWrapper(err, "insufficient_user_quota", http.StatusForbidden) + return errorWrapper(errors.New("user quota is not enough"), "insufficient_user_quota", http.StatusForbidden) } req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) diff --git a/controller/relay-text.go b/controller/relay-text.go index 5a5f355b..3041e3a9 100644 --- a/controller/relay-text.go +++ b/controller/relay-text.go @@ -204,6 +204,9 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { if err != nil { return errorWrapper(err, "get_user_quota_failed", http.StatusInternalServerError) } + if userQuota-preConsumedQuota < 0 { + return errorWrapper(errors.New("user quota is not enough"), "insufficient_user_quota", http.StatusForbidden) + } err = model.CacheDecreaseUserQuota(userId, preConsumedQuota) if err != nil { return errorWrapper(err, "decrease_user_quota_failed", http.StatusInternalServerError) From 3cac45dc855e787084645d597dc1ab721fc42b01 Mon Sep 17 00:00:00 2001 From: JustSong Date: Sun, 1 Oct 2023 13:16:25 +0800 Subject: [PATCH 09/17] docs: update readme --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 49766d8b..df3f85f5 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,7 @@ docker run --name chatgpt-web -d -p 3002:3002 -e OPENAI_API_BASE_URL=https://ope 部署到 Zeabur
-> Zeabur 的服务器在国外,自动解决了网络的问题,同时免费的额度也足够个人使用。 +> Zeabur 的服务器在国外,自动解决了网络的问题,同时免费的额度也足够个人使用 1. 首先 fork 一份代码。 2. 进入 [Zeabur](https://zeabur.com?referralCode=songquanpeng),登录,进入控制台。 @@ -254,6 +254,17 @@ docker run --name chatgpt-web -d -p 3002:3002 -e OPENAI_API_BASE_URL=https://ope
+
+部署到 Render +
+ +> Render 提供免费额度,绑卡后可以进一步提升额度 + +Render 可以直接部署 docker 镜像,不需要 fork 仓库:https://dashboard.render.com + +
+
+ ## 配置 系统本身开箱即用。 From 0f6c132a80f22220967331f4204323a7ab972bcc Mon Sep 17 00:00:00 2001 From: JustSong Date: Sun, 1 Oct 2023 13:26:42 +0800 Subject: [PATCH 10/17] docs: update readme --- README.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index df3f85f5..8af3abf0 100644 --- a/README.md +++ b/README.md @@ -106,11 +106,17 @@ _✨ 通过标准的 OpenAI API 格式访问所有的大模型,开箱即用 ## 部署 ### 基于 Docker 进行部署 -部署命令:`docker run --name one-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/one-api:/data justsong/one-api` +```shell +# 使用 SQLite 的部署命令: +docker run --name one-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/one-api:/data justsong/one-api +# 使用 MySQL 的部署命令,在上面的基础上添加 `-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi"`,请自行修改数据库连接参数,不清楚如何修改请参见下面环境变量一节。 +# 例如: +docker run --name one-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/one-api:/data justsong/one-api +``` 其中,`-p 3000:3000` 中的第一个 `3000` 是宿主机的端口,可以根据需要进行修改。 -数据将会保存在宿主机的 `/home/ubuntu/data/one-api` 目录,请确保该目录存在且具有写入权限,或者更改为合适的目录。 +数据和日志将会保存在宿主机的 `/home/ubuntu/data/one-api` 目录,请确保该目录存在且具有写入权限,或者更改为合适的目录。 如果启动失败,请添加 `--privileged=true`,具体参考 https://github.com/songquanpeng/one-api/issues/482 。 @@ -292,10 +298,11 @@ OPENAI_API_BASE="https://:/v1" ```mermaid graph LR A(用户) - A --->|请求| B(One API) + A --->|使用 One API 分发的 key 进行请求| B(One API) B -->|中继请求| C(OpenAI) B -->|中继请求| D(Azure) - B -->|中继请求| E(其他下游渠道) + B -->|中继请求| E(其他 OpenAI API 格式下游渠道) + B -->|中继并修改请求体和返回体| F(非 OpenAI API 格式下游渠道) ``` 可以通过在令牌后面添加渠道 ID 的方式指定使用哪一个渠道处理本次请求,例如:`Authorization: Bearer ONE_API_KEY-CHANNEL_ID`。 From 4701897e2e5115310ef5ba78ccbec8fe2a43475b Mon Sep 17 00:00:00 2001 From: JustSong Date: Mon, 2 Oct 2023 12:11:02 +0800 Subject: [PATCH 11/17] chore: sync database indices with pro version --- model/log.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/model/log.go b/model/log.go index c189e01d..d26da9a2 100644 --- a/model/log.go +++ b/model/log.go @@ -8,14 +8,14 @@ import ( ) type Log struct { - Id int `json:"id"` + Id int `json:"id;index:idx_created_at_id,priority:1"` UserId int `json:"user_id" gorm:"index"` - CreatedAt int64 `json:"created_at" gorm:"bigint;index"` - Type int `json:"type" gorm:"index"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:2;index:idx_created_at_type"` + Type int `json:"type" gorm:"index:idx_created_at_type"` Content string `json:"content"` - Username string `json:"username" gorm:"index;default:''"` + Username string `json:"username" gorm:"index:index_username_model_name,priority:2;default:''"` TokenName string `json:"token_name" gorm:"index;default:''"` - ModelName string `json:"model_name" gorm:"index;default:''"` + ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"` Quota int `json:"quota" gorm:"default:0"` PromptTokens int `json:"prompt_tokens" gorm:"default:0"` CompletionTokens int `json:"completion_tokens" gorm:"default:0"` From cbd62011b89e06390f8090b3766da7f82cf53060 Mon Sep 17 00:00:00 2001 From: JustSong Date: Mon, 2 Oct 2023 12:13:30 +0800 Subject: [PATCH 12/17] chore: add database migration prompt --- model/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/model/main.go b/model/main.go index d422c4e0..0e962049 100644 --- a/model/main.go +++ b/model/main.go @@ -81,6 +81,7 @@ func InitDB() (err error) { if !common.IsMasterNode { return nil } + common.SysLog("database migration started") err = db.AutoMigrate(&Channel{}) if err != nil { return err From 8d34b7a77ea7915a029c089e497510e0baf59858 Mon Sep 17 00:00:00 2001 From: JustSong Date: Mon, 2 Oct 2023 13:06:27 +0800 Subject: [PATCH 13/17] feat: able to delete all manually disabled channels (close #539) --- common/constants.go | 7 ++- controller/channel-test.go | 2 +- controller/channel.go | 17 +++++ model/channel.go | 5 ++ router/api-router.go | 1 + web/src/components/ChannelsTable.js | 97 ++++++++++++++++++++++------- 6 files changed, 101 insertions(+), 28 deletions(-) diff --git a/common/constants.go b/common/constants.go index fe412e1a..d07dcc8a 100644 --- a/common/constants.go +++ b/common/constants.go @@ -156,9 +156,10 @@ const ( ) const ( - ChannelStatusUnknown = 0 - ChannelStatusEnabled = 1 // don't use 0, 0 is the default value! - ChannelStatusDisabled = 2 // also don't use 0 + ChannelStatusUnknown = 0 + ChannelStatusEnabled = 1 // don't use 0, 0 is the default value! + ChannelStatusManuallyDisabled = 2 // also don't use 0 + ChannelStatusAutoDisabled = 3 ) const ( diff --git a/controller/channel-test.go b/controller/channel-test.go index f7a565a2..1974ef6e 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -141,7 +141,7 @@ func disableChannel(channelId int, channelName string, reason string) { if common.RootUserEmail == "" { common.RootUserEmail = model.GetRootUserEmail() } - model.UpdateChannelStatusById(channelId, common.ChannelStatusDisabled) + model.UpdateChannelStatusById(channelId, common.ChannelStatusAutoDisabled) subject := fmt.Sprintf("通道「%s」(#%d)已被禁用", channelName, channelId) content := fmt.Sprintf("通道「%s」(#%d)已被禁用,原因:%s", channelName, channelId, reason) err := common.SendEmail(subject, common.RootUserEmail, content) diff --git a/controller/channel.go b/controller/channel.go index 50b2b5f6..41a55550 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -127,6 +127,23 @@ func DeleteChannel(c *gin.Context) { return } +func DeleteManuallyDisabledChannel(c *gin.Context) { + rows, err := model.DeleteChannelByStatus(common.ChannelStatusManuallyDisabled) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": rows, + }) + return +} + func UpdateChannel(c *gin.Context) { channel := model.Channel{} err := c.ShouldBindJSON(&channel) diff --git a/model/channel.go b/model/channel.go index 61fe9093..36bb78a5 100644 --- a/model/channel.go +++ b/model/channel.go @@ -176,3 +176,8 @@ func updateChannelUsedQuota(id int, quota int) { common.SysError("failed to update channel used quota: " + err.Error()) } } + +func DeleteChannelByStatus(status int64) (int64, error) { + result := DB.Where("status = ?", status).Delete(&Channel{}) + return result.RowsAffected, result.Error +} diff --git a/router/api-router.go b/router/api-router.go index d12bc54b..5ec385dc 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -74,6 +74,7 @@ func SetApiRouter(router *gin.Engine) { channelRoute.GET("/update_balance/:id", controller.UpdateChannelBalance) channelRoute.POST("/", controller.AddChannel) channelRoute.PUT("/", controller.UpdateChannel) + channelRoute.DELETE("/manually_disabled", controller.DeleteManuallyDisabledChannel) channelRoute.DELETE("/:id", controller.DeleteChannel) } tokenRoute := apiRouter.Group("/token") diff --git a/web/src/components/ChannelsTable.js b/web/src/components/ChannelsTable.js index 7c8457d0..57d45c55 100644 --- a/web/src/components/ChannelsTable.js +++ b/web/src/components/ChannelsTable.js @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import {Button, Form, Input, Label, Pagination, Popup, Table} from 'semantic-ui-react'; +import { Button, Form, Input, Label, Pagination, Popup, Table } from 'semantic-ui-react'; import { Link } from 'react-router-dom'; import { API, showError, showInfo, showNotice, showSuccess, timestamp2string } from '../helpers'; @@ -96,7 +96,7 @@ const ChannelsTable = () => { }); }, []); - const manageChannel = async (id, action, idx, priority) => { + const manageChannel = async (id, action, idx, value) => { let data = { id }; let res; switch (action) { @@ -112,10 +112,20 @@ const ChannelsTable = () => { res = await API.put('/api/channel/', data); break; case 'priority': - if (priority === '') { + if (value === '') { return; } - data.priority = parseInt(priority); + data.priority = parseInt(value); + res = await API.put('/api/channel/', data); + break; + case 'weight': + if (value === '') { + return; + } + data.weight = parseInt(value); + if (data.weight < 0) { + data.weight = 0; + } res = await API.put('/api/channel/', data); break; } @@ -142,9 +152,23 @@ const ChannelsTable = () => { return ; case 2: return ( - + + 已禁用 + } + content='本渠道被手动禁用' + basic + /> + ); + case 3: + return ( + + 已禁用 + } + content='本渠道被程序自动禁用' + basic + /> ); default: return ( @@ -202,7 +226,7 @@ const ChannelsTable = () => { showInfo(`通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。`); } else { showError(message); - showNotice("当前版本测试是通过按照 OpenAI API 格式使用 gpt-3.5-turbo 模型进行非流式请求实现的,因此测试报错并不一定代表通道不可用,该功能后续会修复。") + showNotice('当前版本测试是通过按照 OpenAI API 格式使用 gpt-3.5-turbo 模型进行非流式请求实现的,因此测试报错并不一定代表通道不可用,该功能后续会修复。'); } }; @@ -216,6 +240,17 @@ const ChannelsTable = () => { } }; + const deleteAllManuallyDisabledChannels = async () => { + const res = await API.delete(`/api/channel/manually_disabled`); + const { success, message, data } = res.data; + if (success) { + showSuccess(`已删除所有手动禁用渠道,共计 ${data} 个`); + await refresh(); + } else { + showError(message); + } + }; + const updateChannelBalance = async (id, name, idx) => { const res = await API.get(`/api/channel/update_balance/${id}/`); const { success, message, balance } = res.data; @@ -343,10 +378,10 @@ const ChannelsTable = () => { 余额 { - sortChannel('priority'); - }} + style={{ cursor: 'pointer' }} + onClick={() => { + sortChannel('priority'); + }} > 优先级 @@ -390,18 +425,18 @@ const ChannelsTable = () => { { - manageChannel( - channel.id, - 'priority', - idx, - event.target.value, - ); - }}> - - } - content='渠道选择优先级,越高越优先' - basic + trigger={ { + manageChannel( + channel.id, + 'priority', + idx, + event.target.value + ); + }}> + + } + content='渠道选择优先级,越高越优先' + basic /> @@ -481,6 +516,20 @@ const ChannelsTable = () => { + + 删除所有手动禁用渠道 + + } + on='click' + flowing + hoverable + > + + Date: Mon, 2 Oct 2023 13:15:35 +0800 Subject: [PATCH 14/17] docs: update readme --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 8af3abf0..98e38b8e 100644 --- a/README.md +++ b/README.md @@ -389,6 +389,12 @@ https://openai.justsong.cn + 检查是否启用了 HTTPS,浏览器会拦截 HTTPS 域名下的 HTTP 请求。 6. 报错:`当前分组负载已饱和,请稍后再试` + 上游通道 429 了。 +7. 升级之后我的数据会丢失吗? + + 如果使用 MySQL,不会。 + + 如果使用 SQLite,需要按照我所给的部署命令挂载 volume 持久化 one-api.db 数据库文件,否则容器重启后数据会丢失。 +8. 升级之前数据库需要做变更吗? + + 一般情况下不需要,系统将在初始化的时候自动调整。 + + 如果需要的话,我会在更新日志中说明,并给出脚本。 ## 相关项目 * [FastGPT](https://github.com/labring/FastGPT): 基于 LLM 大语言模型的知识库问答系统 From a85ecace2e033f63ee695ac0688a0b7f6a669946 Mon Sep 17 00:00:00 2001 From: JustSong Date: Tue, 3 Oct 2023 12:51:53 +0800 Subject: [PATCH 15/17] chore: delete 360's 360GPT_S2_V9.4 --- common/model-ratio.go | 1 - controller/model.go | 9 --------- 2 files changed, 10 deletions(-) diff --git a/common/model-ratio.go b/common/model-ratio.go index 4b3dd763..8156afc3 100644 --- a/common/model-ratio.go +++ b/common/model-ratio.go @@ -59,7 +59,6 @@ var ModelRatio = map[string]float64{ "embedding-bert-512-v1": 0.0715, // ¥0.001 / 1k tokens "embedding_s1_v1": 0.0715, // ¥0.001 / 1k tokens "semantic_similarity_s1_v1": 0.0715, // ¥0.001 / 1k tokens - "360GPT_S2_V9.4": 0.8572, // ¥0.012 / 1k tokens } func ModelRatio2JSONString() string { diff --git a/controller/model.go b/controller/model.go index ae2061b3..593bdaba 100644 --- a/controller/model.go +++ b/controller/model.go @@ -423,15 +423,6 @@ func init() { Root: "semantic_similarity_s1_v1", Parent: nil, }, - { - Id: "360GPT_S2_V9.4", - Object: "model", - Created: 1677649963, - OwnedBy: "360", - Permission: permission, - Root: "360GPT_S2_V9.4", - Parent: nil, - }, } openAIModelsMap = make(map[string]OpenAIModels) for _, model := range openAIModels { From d663de3e3a65040231cf525dd326829699acf6ae Mon Sep 17 00:00:00 2001 From: JustSong Date: Tue, 3 Oct 2023 12:52:45 +0800 Subject: [PATCH 16/17] chore: delete 360's 360GPT_S2_V9.4 --- web/src/pages/Channel/EditChannel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/pages/Channel/EditChannel.js b/web/src/pages/Channel/EditChannel.js index 9b128591..e11912bd 100644 --- a/web/src/pages/Channel/EditChannel.js +++ b/web/src/pages/Channel/EditChannel.js @@ -76,7 +76,7 @@ const EditChannel = () => { localModels = ['SparkDesk']; break; case 19: - localModels = ['360GPT_S2_V9', 'embedding-bert-512-v1', 'embedding_s1_v1', 'semantic_similarity_s1_v1', '360GPT_S2_V9.4']; + localModels = ['360GPT_S2_V9', 'embedding-bert-512-v1', 'embedding_s1_v1', 'semantic_similarity_s1_v1']; break; } setInputs((inputs) => ({ ...inputs, models: localModels })); From b4b4acc288d7c1d9b942a85abe0f2b505ecb62a7 Mon Sep 17 00:00:00 2001 From: JustSong Date: Tue, 3 Oct 2023 14:19:03 +0800 Subject: [PATCH 17/17] feat: support Tencent's model (close #519) --- README.md | 1 + common/constants.go | 48 +++-- common/model-ratio.go | 1 + controller/model.go | 9 + controller/option.go | 2 +- controller/relay-tencent.go | 287 +++++++++++++++++++++++++ controller/relay-text.go | 43 ++++ middleware/distributor.go | 4 +- web/src/constants/channel.constants.js | 1 + web/src/pages/Channel/EditChannel.js | 5 + 10 files changed, 375 insertions(+), 26 deletions(-) create mode 100644 controller/relay-tencent.go diff --git a/README.md b/README.md index 98e38b8e..cb641947 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ _✨ 通过标准的 OpenAI API 格式访问所有的大模型,开箱即用 + [x] [讯飞星火认知大模型](https://www.xfyun.cn/doc/spark/Web.html) + [x] [智谱 ChatGLM 系列模型](https://bigmodel.cn) + [x] [360 智脑](https://ai.360.cn) + + [x] [腾讯混元大模型](https://cloud.tencent.com/document/product/1729) 2. 支持配置镜像以及众多第三方代理服务: + [x] [OpenAI-SB](https://openai-sb.com) + [x] [CloseAI](https://console.closeai-asia.com/r/2412) diff --git a/common/constants.go b/common/constants.go index d07dcc8a..a0361c35 100644 --- a/common/constants.go +++ b/common/constants.go @@ -186,30 +186,32 @@ const ( ChannelTypeOpenRouter = 20 ChannelTypeAIProxyLibrary = 21 ChannelTypeFastGPT = 22 + ChannelTypeTencent = 23 ) var ChannelBaseURLs = []string{ - "", // 0 - "https://api.openai.com", // 1 - "https://oa.api2d.net", // 2 - "", // 3 - "https://api.closeai-proxy.xyz", // 4 - "https://api.openai-sb.com", // 5 - "https://api.openaimax.com", // 6 - "https://api.ohmygpt.com", // 7 - "", // 8 - "https://api.caipacity.com", // 9 - "https://api.aiproxy.io", // 10 - "", // 11 - "https://api.api2gpt.com", // 12 - "https://api.aigc2d.com", // 13 - "https://api.anthropic.com", // 14 - "https://aip.baidubce.com", // 15 - "https://open.bigmodel.cn", // 16 - "https://dashscope.aliyuncs.com", // 17 - "", // 18 - "https://ai.360.cn", // 19 - "https://openrouter.ai/api", // 20 - "https://api.aiproxy.io", // 21 - "https://fastgpt.run/api/openapi", // 22 + "", // 0 + "https://api.openai.com", // 1 + "https://oa.api2d.net", // 2 + "", // 3 + "https://api.closeai-proxy.xyz", // 4 + "https://api.openai-sb.com", // 5 + "https://api.openaimax.com", // 6 + "https://api.ohmygpt.com", // 7 + "", // 8 + "https://api.caipacity.com", // 9 + "https://api.aiproxy.io", // 10 + "", // 11 + "https://api.api2gpt.com", // 12 + "https://api.aigc2d.com", // 13 + "https://api.anthropic.com", // 14 + "https://aip.baidubce.com", // 15 + "https://open.bigmodel.cn", // 16 + "https://dashscope.aliyuncs.com", // 17 + "", // 18 + "https://ai.360.cn", // 19 + "https://openrouter.ai/api", // 20 + "https://api.aiproxy.io", // 21 + "https://fastgpt.run/api/openapi", // 22 + "https://hunyuan.cloud.tencent.com", //23 } diff --git a/common/model-ratio.go b/common/model-ratio.go index 8156afc3..f1ce99a5 100644 --- a/common/model-ratio.go +++ b/common/model-ratio.go @@ -59,6 +59,7 @@ var ModelRatio = map[string]float64{ "embedding-bert-512-v1": 0.0715, // ¥0.001 / 1k tokens "embedding_s1_v1": 0.0715, // ¥0.001 / 1k tokens "semantic_similarity_s1_v1": 0.0715, // ¥0.001 / 1k tokens + "hunyuan": 7.143, // ¥0.1 / 1k tokens // https://cloud.tencent.com/document/product/1729/97731#e0e6be58-60c8-469f-bdeb-6c264ce3b4d0 } func ModelRatio2JSONString() string { diff --git a/controller/model.go b/controller/model.go index 593bdaba..e9b64514 100644 --- a/controller/model.go +++ b/controller/model.go @@ -423,6 +423,15 @@ func init() { Root: "semantic_similarity_s1_v1", Parent: nil, }, + { + Id: "hunyuan", + Object: "model", + Created: 1677649963, + OwnedBy: "tencent", + Permission: permission, + Root: "hunyuan", + Parent: nil, + }, } openAIModelsMap = make(map[string]OpenAIModels) for _, model := range openAIModels { diff --git a/controller/option.go b/controller/option.go index 9cf4ff1b..bbf83578 100644 --- a/controller/option.go +++ b/controller/option.go @@ -46,7 +46,7 @@ func UpdateOption(c *gin.Context) { if option.Value == "true" && common.GitHubClientId == "" { c.JSON(http.StatusOK, gin.H{ "success": false, - "message": "无法启用 GitHub OAuth,请先填入 GitHub Client ID 以及 GitHub Client Secret!", + "message": "无法启用 GitHub OAuth,请先填入 GitHub Client Id 以及 GitHub Client Secret!", }) return } diff --git a/controller/relay-tencent.go b/controller/relay-tencent.go new file mode 100644 index 00000000..024468bc --- /dev/null +++ b/controller/relay-tencent.go @@ -0,0 +1,287 @@ +package controller + +import ( + "bufio" + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "github.com/gin-gonic/gin" + "io" + "net/http" + "one-api/common" + "sort" + "strconv" + "strings" +) + +// https://cloud.tencent.com/document/product/1729/97732 + +type TencentMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type TencentChatRequest struct { + AppId int64 `json:"app_id"` // 腾讯云账号的 APPID + SecretId string `json:"secret_id"` // 官网 SecretId + // Timestamp当前 UNIX 时间戳,单位为秒,可记录发起 API 请求的时间。 + // 例如1529223702,如果与当前时间相差过大,会引起签名过期错误 + Timestamp int64 `json:"timestamp"` + // Expired 签名的有效期,是一个符合 UNIX Epoch 时间戳规范的数值, + // 单位为秒;Expired 必须大于 Timestamp 且 Expired-Timestamp 小于90天 + Expired int64 `json:"expired"` + QueryID string `json:"query_id"` //请求 Id,用于问题排查 + // Temperature 较高的数值会使输出更加随机,而较低的数值会使其更加集中和确定 + // 默认 1.0,取值区间为[0.0,2.0],非必要不建议使用,不合理的取值会影响效果 + // 建议该参数和 top_p 只设置1个,不要同时更改 top_p + Temperature float64 `json:"temperature"` + // TopP 影响输出文本的多样性,取值越大,生成文本的多样性越强 + // 默认1.0,取值区间为[0.0, 1.0],非必要不建议使用, 不合理的取值会影响效果 + // 建议该参数和 temperature 只设置1个,不要同时更改 + TopP float64 `json:"top_p"` + // Stream 0:同步,1:流式 (默认,协议:SSE) + // 同步请求超时:60s,如果内容较长建议使用流式 + Stream int `json:"stream"` + // Messages 会话内容, 长度最多为40, 按对话时间从旧到新在数组中排列 + // 输入 content 总数最大支持 3000 token。 + Messages []TencentMessage `json:"messages"` +} + +type TencentError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type TencentUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type TencentResponseChoices struct { + FinishReason string `json:"finish_reason,omitempty"` // 流式结束标志位,为 stop 则表示尾包 + Messages TencentMessage `json:"messages,omitempty"` // 内容,同步模式返回内容,流模式为 null 输出 content 内容总数最多支持 1024token。 + Delta TencentMessage `json:"delta,omitempty"` // 内容,流模式返回内容,同步模式为 null 输出 content 内容总数最多支持 1024token。 +} + +type TencentChatResponse struct { + Choices []TencentResponseChoices `json:"choices,omitempty"` // 结果 + Created string `json:"created,omitempty"` // unix 时间戳的字符串 + Id string `json:"id,omitempty"` // 会话 id + Usage Usage `json:"usage,omitempty"` // token 数量 + Error TencentError `json:"error,omitempty"` // 错误信息 注意:此字段可能返回 null,表示取不到有效值 + Note string `json:"note,omitempty"` // 注释 + ReqID string `json:"req_id,omitempty"` // 唯一请求 Id,每次请求都会返回。用于反馈接口入参 +} + +func requestOpenAI2Tencent(request GeneralOpenAIRequest) *TencentChatRequest { + messages := make([]TencentMessage, 0, len(request.Messages)) + for i := 0; i < len(request.Messages); i++ { + message := request.Messages[i] + if message.Role == "system" { + messages = append(messages, TencentMessage{ + Role: "user", + Content: message.Content, + }) + messages = append(messages, TencentMessage{ + Role: "assistant", + Content: "Okay", + }) + continue + } + messages = append(messages, TencentMessage{ + Content: message.Content, + Role: message.Role, + }) + } + stream := 0 + if request.Stream { + stream = 1 + } + return &TencentChatRequest{ + Timestamp: common.GetTimestamp(), + Expired: common.GetTimestamp() + 24*60*60, + QueryID: common.GetUUID(), + Temperature: request.Temperature, + TopP: request.TopP, + Stream: stream, + Messages: messages, + } +} + +func responseTencent2OpenAI(response *TencentChatResponse) *OpenAITextResponse { + fullTextResponse := OpenAITextResponse{ + Object: "chat.completion", + Created: common.GetTimestamp(), + Usage: response.Usage, + } + if len(response.Choices) > 0 { + choice := OpenAITextResponseChoice{ + Index: 0, + Message: Message{ + Role: "assistant", + Content: response.Choices[0].Messages.Content, + }, + FinishReason: response.Choices[0].FinishReason, + } + fullTextResponse.Choices = append(fullTextResponse.Choices, choice) + } + return &fullTextResponse +} + +func streamResponseTencent2OpenAI(TencentResponse *TencentChatResponse) *ChatCompletionsStreamResponse { + response := ChatCompletionsStreamResponse{ + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: "tencent-hunyuan", + } + if len(TencentResponse.Choices) > 0 { + var choice ChatCompletionsStreamResponseChoice + choice.Delta.Content = TencentResponse.Choices[0].Delta.Content + if TencentResponse.Choices[0].FinishReason == "stop" { + choice.FinishReason = &stopFinishReason + } + response.Choices = append(response.Choices, choice) + } + return &response +} + +func tencentStreamHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, string) { + var responseText string + scanner := bufio.NewScanner(resp.Body) + scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + if i := strings.Index(string(data), "\n"); i >= 0 { + return i + 1, data[0:i], nil + } + if atEOF { + return len(data), data, nil + } + return 0, nil, nil + }) + dataChan := make(chan string) + stopChan := make(chan bool) + go func() { + for scanner.Scan() { + data := scanner.Text() + if len(data) < 5 { // ignore blank line or wrong format + continue + } + if data[:5] != "data:" { + continue + } + data = data[5:] + dataChan <- data + } + stopChan <- true + }() + setEventStreamHeaders(c) + c.Stream(func(w io.Writer) bool { + select { + case data := <-dataChan: + var TencentResponse TencentChatResponse + err := json.Unmarshal([]byte(data), &TencentResponse) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + return true + } + response := streamResponseTencent2OpenAI(&TencentResponse) + if len(response.Choices) != 0 { + responseText += response.Choices[0].Delta.Content + } + jsonResponse, err := json.Marshal(response) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + return true + } + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)}) + return true + case <-stopChan: + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + }) + err := resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), "" + } + return nil, responseText +} + +func tencentHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, *Usage) { + var TencentResponse TencentChatResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return errorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil + } + err = resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + err = json.Unmarshal(responseBody, &TencentResponse) + if err != nil { + return errorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if TencentResponse.Error.Code != 0 { + return &OpenAIErrorWithStatusCode{ + OpenAIError: OpenAIError{ + Message: TencentResponse.Error.Message, + Code: TencentResponse.Error.Code, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := responseTencent2OpenAI(&TencentResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return errorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} + +func parseTencentConfig(config string) (appId int64, secretId string, secretKey string, err error) { + parts := strings.Split(config, "|") + if len(parts) != 3 { + err = errors.New("invalid tencent config") + return + } + appId, err = strconv.ParseInt(parts[0], 10, 64) + secretId = parts[1] + secretKey = parts[2] + return +} + +func getTencentSign(req TencentChatRequest, secretKey string) string { + params := make([]string, 0) + params = append(params, "app_id="+strconv.FormatInt(req.AppId, 10)) + params = append(params, "secret_id="+req.SecretId) + params = append(params, "timestamp="+strconv.FormatInt(req.Timestamp, 10)) + params = append(params, "query_id="+req.QueryID) + params = append(params, "temperature="+strconv.FormatFloat(req.Temperature, 'f', -1, 64)) + params = append(params, "top_p="+strconv.FormatFloat(req.TopP, 'f', -1, 64)) + params = append(params, "stream="+strconv.Itoa(req.Stream)) + params = append(params, "expired="+strconv.FormatInt(req.Expired, 10)) + + var messageStr string + for _, msg := range req.Messages { + messageStr += fmt.Sprintf(`{"role":"%s","content":"%s"},`, msg.Role, msg.Content) + } + messageStr = strings.TrimSuffix(messageStr, ",") + params = append(params, "messages=["+messageStr+"]") + + sort.Sort(sort.StringSlice(params)) + url := "hunyuan.cloud.tencent.com/hyllm/v1/chat/completions?" + strings.Join(params, "&") + mac := hmac.New(sha1.New, []byte(secretKey)) + signURL := url + mac.Write([]byte(signURL)) + sign := mac.Sum([]byte(nil)) + return base64.StdEncoding.EncodeToString(sign) +} diff --git a/controller/relay-text.go b/controller/relay-text.go index 3041e3a9..fba49a7f 100644 --- a/controller/relay-text.go +++ b/controller/relay-text.go @@ -24,6 +24,7 @@ const ( APITypeAli APITypeXunfei APITypeAIProxyLibrary + APITypeTencent ) var httpClient *http.Client @@ -109,6 +110,8 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { apiType = APITypeXunfei case common.ChannelTypeAIProxyLibrary: apiType = APITypeAIProxyLibrary + case common.ChannelTypeTencent: + apiType = APITypeTencent } baseURL := common.ChannelBaseURLs[channelType] requestURL := c.Request.URL.String() @@ -179,6 +182,8 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { if relayMode == RelayModeEmbeddings { fullRequestURL = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding" } + case APITypeTencent: + fullRequestURL = "https://hunyuan.cloud.tencent.com/hyllm/v1/chat/completions" case APITypeAIProxyLibrary: fullRequestURL = fmt.Sprintf("%s/api/library/ask", baseURL) } @@ -285,6 +290,23 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { return errorWrapper(err, "marshal_text_request_failed", http.StatusInternalServerError) } requestBody = bytes.NewBuffer(jsonStr) + case APITypeTencent: + apiKey := c.Request.Header.Get("Authorization") + apiKey = strings.TrimPrefix(apiKey, "Bearer ") + appId, secretId, secretKey, err := parseTencentConfig(apiKey) + if err != nil { + return errorWrapper(err, "invalid_tencent_config", http.StatusInternalServerError) + } + tencentRequest := requestOpenAI2Tencent(textRequest) + tencentRequest.AppId = appId + tencentRequest.SecretId = secretId + jsonStr, err := json.Marshal(tencentRequest) + if err != nil { + return errorWrapper(err, "marshal_text_request_failed", http.StatusInternalServerError) + } + sign := getTencentSign(*tencentRequest, secretKey) + c.Request.Header.Set("Authorization", sign) + requestBody = bytes.NewBuffer(jsonStr) case APITypeAIProxyLibrary: aiProxyLibraryRequest := requestOpenAI2AIProxyLibrary(textRequest) aiProxyLibraryRequest.LibraryId = c.GetString("library_id") @@ -332,6 +354,8 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { if textRequest.Stream { req.Header.Set("X-DashScope-SSE", "enable") } + case APITypeTencent: + req.Header.Set("Authorization", apiKey) default: req.Header.Set("Authorization", "Bearer "+apiKey) } @@ -584,6 +608,25 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { } return nil } + case APITypeTencent: + if isStream { + err, responseText := tencentStreamHandler(c, resp) + if err != nil { + return err + } + textResponse.Usage.PromptTokens = promptTokens + textResponse.Usage.CompletionTokens = countTokenText(responseText, textRequest.Model) + return nil + } else { + err, usage := tencentHandler(c, resp) + if err != nil { + return err + } + if usage != nil { + textResponse.Usage = *usage + } + return nil + } default: return errorWrapper(errors.New("unknown api type"), "unknown_api_type", http.StatusInternalServerError) } diff --git a/middleware/distributor.go b/middleware/distributor.go index 9ded3231..d80945fc 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -25,12 +25,12 @@ func Distribute() func(c *gin.Context) { if ok { id, err := strconv.Atoi(channelId.(string)) if err != nil { - abortWithMessage(c, http.StatusBadRequest, "无效的渠道 ID") + abortWithMessage(c, http.StatusBadRequest, "无效的渠道 Id") return } channel, err = model.GetChannelById(id, true) if err != nil { - abortWithMessage(c, http.StatusBadRequest, "无效的渠道 ID") + abortWithMessage(c, http.StatusBadRequest, "无效的渠道 Id") return } if channel.Status != common.ChannelStatusEnabled { diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index e42afc6e..76407745 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -8,6 +8,7 @@ export const CHANNEL_OPTIONS = [ { key: 18, text: '讯飞星火认知', value: 18, color: 'blue' }, { key: 16, text: '智谱 ChatGLM', value: 16, color: 'violet' }, { key: 19, text: '360 智脑', value: 19, color: 'blue' }, + { key: 23, text: '腾讯混元', value: 23, color: 'teal' }, { key: 8, text: '自定义渠道', value: 8, color: 'pink' }, { key: 22, text: '知识库:FastGPT', value: 22, color: 'blue' }, { key: 21, text: '知识库:AI Proxy', value: 21, color: 'purple' }, diff --git a/web/src/pages/Channel/EditChannel.js b/web/src/pages/Channel/EditChannel.js index e11912bd..82077c1f 100644 --- a/web/src/pages/Channel/EditChannel.js +++ b/web/src/pages/Channel/EditChannel.js @@ -19,6 +19,8 @@ function type2secretPrompt(type) { return '按照如下格式输入:APPID|APISecret|APIKey'; case 22: return '按照如下格式输入:APIKey-AppId,例如:fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041'; + case 23: + return '按照如下格式输入:AppId|SecretId|SecretKey'; default: return '请输入渠道对应的鉴权密钥'; } @@ -78,6 +80,9 @@ const EditChannel = () => { case 19: localModels = ['360GPT_S2_V9', 'embedding-bert-512-v1', 'embedding_s1_v1', 'semantic_similarity_s1_v1']; break; + case 23: + localModels = ['hunyuan']; + break; } setInputs((inputs) => ({ ...inputs, models: localModels })); }