diff --git a/model/redemption.go b/model/redemption.go index 45871a71..9117916f 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -17,7 +17,7 @@ const ( type Redemption struct { Id int `json:"id"` UserId int `json:"user_id"` - Key string `json:"key" gorm:"type:char(32);uniqueIndex"` + Key string `json:"key" gorm:"type:char(32)"` Status int `json:"status" gorm:"default:1"` Name string `json:"name" gorm:"index"` Quota int64 `json:"quota" gorm:"bigint;default:100"` diff --git a/model/token.go b/model/token.go index 91e72a82..2426b730 100644 --- a/model/token.go +++ b/model/token.go @@ -21,7 +21,7 @@ const ( type Token struct { Id int `json:"id"` UserId int `json:"user_id"` - Key string `json:"key" gorm:"type:char(48);uniqueIndex"` + Key string `json:"key" gorm:"type:char(48)"` Status int `json:"status" gorm:"default:1"` Name string `json:"name" gorm:"index" ` CreatedTime int64 `json:"created_time" gorm:"bigint"` diff --git a/model/user.go b/model/user.go index a964a0d7..1956d2ce 100644 --- a/model/user.go +++ b/model/user.go @@ -30,7 +30,7 @@ const ( // Otherwise, the sensitive information will be saved on local storage in plain text! type User struct { Id int `json:"id"` - Username string `json:"username" gorm:"unique;index" validate:"max=12"` + Username string `json:"username" gorm:"index" validate:"max=12"` Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"` DisplayName string `json:"display_name" gorm:"index" validate:"max=20"` Role int `json:"role" gorm:"type:int;default:1"` // admin, util @@ -40,13 +40,13 @@ type User struct { WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"` LarkId string `json:"lark_id" gorm:"column:lark_id;index"` OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"` - VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database! - AccessToken string `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management + VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database! + AccessToken string `json:"access_token" gorm:"type:char(32);column:access_token"` // this token is for system management Quota int64 `json:"quota" gorm:"bigint;default:0"` UsedQuota int64 `json:"used_quota" gorm:"bigint;default:0;column:used_quota"` // used quota RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number Group string `json:"group" gorm:"type:varchar(32);default:'default'"` - AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"` + AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code"` InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"` } diff --git a/relay/adaptor.go b/relay/adaptor.go index 03e83903..ad69aa82 100644 --- a/relay/adaptor.go +++ b/relay/adaptor.go @@ -64,6 +64,9 @@ func GetAdaptor(apiType int) adaptor.Adaptor { return &proxy.Adaptor{} case apitype.Replicate: return &replicate.Adaptor{} + case apitype.CozeV3: + return &coze.AdaptorV3{} + } return nil } diff --git a/relay/adaptor/coze/adaptor_v3.go b/relay/adaptor/coze/adaptor_v3.go new file mode 100644 index 00000000..947b3846 --- /dev/null +++ b/relay/adaptor/coze/adaptor_v3.go @@ -0,0 +1,75 @@ +package coze + +import ( + "errors" + "fmt" + "github.com/gin-gonic/gin" + "github.com/songquanpeng/one-api/relay/adaptor" + "github.com/songquanpeng/one-api/relay/adaptor/openai" + "github.com/songquanpeng/one-api/relay/meta" + "github.com/songquanpeng/one-api/relay/model" + "io" + "net/http" +) + +type AdaptorV3 struct { + meta *meta.Meta +} + +func (a *AdaptorV3) Init(meta *meta.Meta) { + a.meta = meta +} + +func (a *AdaptorV3) GetRequestURL(meta *meta.Meta) (string, error) { + return fmt.Sprintf("%s/v3/chat", meta.BaseURL), nil +} + +func (a *AdaptorV3) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error { + adaptor.SetupCommonRequestHeader(c, req, meta) + req.Header.Set("Authorization", "Bearer "+meta.APIKey) + return nil +} + +func (a *AdaptorV3) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + request.User = a.meta.Config.UserID + return V3ConvertRequest(*request), nil +} + +func (a *AdaptorV3) ConvertImageRequest(request *model.ImageRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *AdaptorV3) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { + return adaptor.DoRequestHelper(a, c, meta, requestBody) +} + +func (a *AdaptorV3) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { + var responseText *string + if meta.IsStream { + err, responseText = V3StreamHandler(c, resp) + } else { + err, responseText = V3Handler(c, resp, meta.PromptTokens, meta.ActualModelName) + } + if responseText != nil { + usage = openai.ResponseText2Usage(*responseText, meta.ActualModelName, meta.PromptTokens) + } else { + usage = &model.Usage{} + } + usage.PromptTokens = meta.PromptTokens + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + return +} + +func (a *AdaptorV3) GetModelList() []string { + return ModelList +} + +func (a *AdaptorV3) GetChannelName() string { + return "CozeV3" +} diff --git a/relay/adaptor/coze/helper.go b/relay/adaptor/coze/helper.go index 0396afcb..3c818426 100644 --- a/relay/adaptor/coze/helper.go +++ b/relay/adaptor/coze/helper.go @@ -1,6 +1,9 @@ package coze -import "github.com/songquanpeng/one-api/relay/adaptor/coze/constant/event" +import ( + "github.com/songquanpeng/one-api/relay/adaptor/coze/constant/event" + "strings" +) func event2StopReason(e *string) string { if e == nil || *e == event.Message { @@ -8,3 +11,16 @@ func event2StopReason(e *string) string { } return "stop" } + +func splitOnDoubleNewline(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\n"); i >= 0 { + return i + 1, data[0:i], nil + } + if atEOF { + return len(data), data, nil + } + return 0, nil, nil +} diff --git a/relay/adaptor/coze/main.go b/relay/adaptor/coze/main.go index d0402a76..98587374 100644 --- a/relay/adaptor/coze/main.go +++ b/relay/adaptor/coze/main.go @@ -4,19 +4,18 @@ import ( "bufio" "encoding/json" "fmt" - "github.com/songquanpeng/one-api/common/render" - "io" - "net/http" - "strings" - "github.com/gin-gonic/gin" "github.com/songquanpeng/one-api/common" "github.com/songquanpeng/one-api/common/conv" "github.com/songquanpeng/one-api/common/helper" "github.com/songquanpeng/one-api/common/logger" + "github.com/songquanpeng/one-api/common/render" "github.com/songquanpeng/one-api/relay/adaptor/coze/constant/messagetype" "github.com/songquanpeng/one-api/relay/adaptor/openai" "github.com/songquanpeng/one-api/relay/model" + "io" + "net/http" + "strings" ) // https://www.coze.com/open @@ -57,6 +56,32 @@ func ConvertRequest(textRequest model.GeneralOpenAIRequest) *Request { return &cozeRequest } +func V3ConvertRequest(textRequest model.GeneralOpenAIRequest) *V3Request { + cozeRequest := V3Request{ + UserId: textRequest.User, + Stream: textRequest.Stream, + BotId: strings.TrimPrefix(textRequest.Model, "bot-"), + } + if cozeRequest.UserId == "" { + cozeRequest.UserId = "any" + } + for i, message := range textRequest.Messages { + if i == len(textRequest.Messages)-1 { + cozeRequest.AdditionalMessages = append(cozeRequest.AdditionalMessages, Message{ + Role: "user", + Content: message.CozeV3StringContent(), + }) + continue + } + cozeMessage := Message{ + Role: message.Role, + Content: message.CozeV3StringContent(), + } + cozeRequest.AdditionalMessages = append(cozeRequest.AdditionalMessages, cozeMessage) + } + return &cozeRequest +} + func StreamResponseCoze2OpenAI(cozeResponse *StreamResponse) (*openai.ChatCompletionsStreamResponse, *Response) { var response *Response var stopReason string @@ -80,6 +105,28 @@ func StreamResponseCoze2OpenAI(cozeResponse *StreamResponse) (*openai.ChatComple return &openaiResponse, response } +func V3StreamResponseCoze2OpenAI(cozeResponse *V3StreamResponse) (*openai.ChatCompletionsStreamResponse, *Response) { + var response *Response + var choice openai.ChatCompletionsStreamResponseChoice + + choice.Delta.Role = cozeResponse.Role + choice.Delta.Content = cozeResponse.Content + + var openaiResponse openai.ChatCompletionsStreamResponse + openaiResponse.Object = "chat.completion.chunk" + openaiResponse.Choices = []openai.ChatCompletionsStreamResponseChoice{choice} + openaiResponse.Id = cozeResponse.ConversationId + + if cozeResponse.Usage.TokenCount > 0 { + openaiResponse.Usage = &model.Usage{ + PromptTokens: cozeResponse.Usage.InputCount, + CompletionTokens: cozeResponse.Usage.OutputCount, + TotalTokens: cozeResponse.Usage.TokenCount, + } + } + return &openaiResponse, response +} + func ResponseCoze2OpenAI(cozeResponse *Response) *openai.TextResponse { var responseText string for _, message := range cozeResponse.Messages { @@ -107,6 +154,26 @@ func ResponseCoze2OpenAI(cozeResponse *Response) *openai.TextResponse { return &fullTextResponse } +func V3ResponseCoze2OpenAI(cozeResponse *V3Response) *openai.TextResponse { + choice := openai.TextResponseChoice{ + Index: 0, + Message: model.Message{ + Role: "assistant", + Content: cozeResponse.Data.Content, + Name: nil, + }, + FinishReason: "stop", + } + fullTextResponse := openai.TextResponse{ + Id: fmt.Sprintf("chatcmpl-%s", cozeResponse.Data.ConversationId), + Model: "coze-bot", + Object: "chat.completion", + Created: helper.GetTimestamp(), + Choices: []openai.TextResponseChoice{choice}, + } + return &fullTextResponse +} + func StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *string) { var responseText string createdTime := helper.GetTimestamp() @@ -162,6 +229,63 @@ func StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusC return nil, &responseText } +func V3StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *string) { + var responseText string + createdTime := helper.GetTimestamp() + scanner := bufio.NewScanner(resp.Body) + scanner.Split(splitOnDoubleNewline) + common.SetEventStreamHeaders(c) + var modelName string + for scanner.Scan() { + part := scanner.Text() + part = strings.TrimPrefix(part, "\n") + parts := strings.Split(part, "\n") + if len(parts) != 2 { + continue + } + if !strings.HasPrefix(parts[0], "event:") || !strings.HasPrefix(parts[1], "data:") { + continue + } + event, data := strings.TrimSpace(parts[0][6:]), strings.TrimSpace(parts[1][5:]) + if event == "conversation.message.delta" || event == "conversation.chat.completed" { + data = strings.TrimSuffix(data, "\r") + var cozeResponse V3StreamResponse + err := json.Unmarshal([]byte(data), &cozeResponse) + if err != nil { + logger.SysError("error unmarshalling stream response: " + err.Error()) + continue + } + + response, _ := V3StreamResponseCoze2OpenAI(&cozeResponse) + if response == nil { + continue + } + + for _, choice := range response.Choices { + responseText += conv.AsString(choice.Delta.Content) + } + response.Model = modelName + response.Created = createdTime + + err = render.ObjectData(c, response) + if err != nil { + logger.SysError(err.Error()) + } + } + } + if err := scanner.Err(); err != nil { + logger.SysError("error reading stream: " + err.Error()) + } + + render.Done(c) + err := resp.Body.Close() + if err != nil { + return openai.ErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + + return nil, &responseText +} + func Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *string) { responseBody, err := io.ReadAll(resp.Body) if err != nil { @@ -200,3 +324,42 @@ func Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName st } return nil, &responseText } + +func V3Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *string) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return openai.ErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil + } + err = resp.Body.Close() + if err != nil { + return openai.ErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + var cozeResponse V3Response + err = json.Unmarshal(responseBody, &cozeResponse) + if err != nil { + return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if cozeResponse.Code != 0 { + return &model.ErrorWithStatusCode{ + Error: model.Error{ + Message: cozeResponse.Msg, + Code: cozeResponse.Code, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := V3ResponseCoze2OpenAI(&cozeResponse) + fullTextResponse.Model = modelName + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return openai.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) + var responseText string + if len(fullTextResponse.Choices) > 0 { + responseText = fullTextResponse.Choices[0].Message.StringContent() + } + return nil, &responseText +} diff --git a/relay/adaptor/coze/model.go b/relay/adaptor/coze/model.go index d0afecfe..b86e2a67 100644 --- a/relay/adaptor/coze/model.go +++ b/relay/adaptor/coze/model.go @@ -2,9 +2,9 @@ package coze type Message struct { Role string `json:"role"` - Type string `json:"type"` + Type string `json:"type,omitempty"` Content string `json:"content"` - ContentType string `json:"content_type"` + ContentType string `json:"content_type,omitempty"` } type ErrorInformation struct { @@ -36,3 +36,52 @@ type StreamResponse struct { ConversationId string `json:"conversation_id,omitempty"` ErrorInformation *ErrorInformation `json:"error_information,omitempty"` } + +type V3StreamResponse struct { + Id string `json:"id"` + ConversationId string `json:"conversation_id"` + BotId string `json:"bot_id"` + Role string `json:"role"` + Type string `json:"type"` + Content string `json:"content"` + ContentType string `json:"content_type"` + ChatId string `json:"chat_id"` + CreatedAt int `json:"created_at"` + CompletedAt int `json:"completed_at"` + LastError struct { + Code int `json:"code"` + Msg string `json:"msg"` + } `json:"last_error"` + Status string `json:"status"` + Usage struct { + TokenCount int `json:"token_count"` + OutputCount int `json:"output_count"` + InputCount int `json:"input_count"` + } `json:"usage"` + SectionId string `json:"section_id"` +} + +type V3Response struct { + Data struct { + Id string `json:"id"` + ConversationId string `json:"conversation_id"` + BotId string `json:"bot_id"` + Content string `json:"content"` + ContentType string `json:"content_type"` + CreatedAt int `json:"created_at"` + LastError struct { + Code int `json:"code"` + Msg string `json:"msg"` + } `json:"last_error"` + Status string `json:"status"` + } `json:"data"` + Code int `json:"code"` + Msg string `json:"msg"` +} + +type V3Request struct { + BotId string `json:"bot_id"` + UserId string `json:"user_id"` + AdditionalMessages []Message `json:"additional_messages"` + Stream bool `json:"stream"` +} diff --git a/relay/apitype/define.go b/relay/apitype/define.go index 0c6a5ff1..30a7a65b 100644 --- a/relay/apitype/define.go +++ b/relay/apitype/define.go @@ -20,6 +20,6 @@ const ( VertexAI Proxy Replicate - + CozeV3 Dummy // this one is only for count, do not add any channel after this ) diff --git a/relay/channeltype/define.go b/relay/channeltype/define.go index f54d0e30..07fc77f5 100644 --- a/relay/channeltype/define.go +++ b/relay/channeltype/define.go @@ -48,5 +48,6 @@ const ( SiliconFlow XAI Replicate + CozeV3 Dummy ) diff --git a/relay/channeltype/helper.go b/relay/channeltype/helper.go index 8839b30a..e8adb7df 100644 --- a/relay/channeltype/helper.go +++ b/relay/channeltype/helper.go @@ -29,6 +29,8 @@ func ToAPIType(channelType int) int { apiType = apitype.AwsClaude case Coze: apiType = apitype.Coze + case CozeV3: + apiType = apitype.CozeV3 case Cohere: apiType = apitype.Cohere case Cloudflare: diff --git a/relay/channeltype/url.go b/relay/channeltype/url.go index 8e271f4e..15f8e0a0 100644 --- a/relay/channeltype/url.go +++ b/relay/channeltype/url.go @@ -48,6 +48,7 @@ var ChannelBaseURLs = []string{ "https://api.siliconflow.cn", // 44 "https://api.x.ai", // 45 "https://api.replicate.com/v1/models/", // 46 + "https://api.coze.cn", // 47 } func init() { diff --git a/relay/model/message.go b/relay/model/message.go index b908f989..0a437dd8 100644 --- a/relay/model/message.go +++ b/relay/model/message.go @@ -1,5 +1,7 @@ package model +import "encoding/json" + type Message struct { Role string `json:"role,omitempty"` Content any `json:"content,omitempty"` @@ -37,6 +39,53 @@ func (m Message) StringContent() string { return "" } +func (m Message) CozeV3StringContent() string { + content, ok := m.Content.(string) + if ok { + return content + } + contentList, ok := m.Content.([]any) + if ok { + contents := make([]map[string]any, 0) + var contentStr string + for _, contentItem := range contentList { + contentMap, ok := contentItem.(map[string]any) + if !ok { + continue + } + switch contentMap["type"] { + case "text": + if subStr, ok := contentMap["text"].(string); ok { + contents = append(contents, map[string]any{ + "type": "text", + "text": subStr, + }) + } + case "image_url": + if subStr, ok := contentMap["image_url"].(string); ok { + contents = append(contents, map[string]any{ + "type": "image", + "file_url": subStr, + }) + } + case "file": + if subStr, ok := contentMap["image_url"].(string); ok { + contents = append(contents, map[string]any{ + "type": "file", + "file_url": subStr, + }) + } + } + } + if len(contents) > 0 { + b, _ := json.Marshal(contents) + return string(b) + } + return contentStr + } + return "" +} + func (m Message) ParseContent() []MessageContent { var contentList []MessageContent content, ok := m.Content.(string) diff --git a/web/air/src/constants/channel.constants.js b/web/air/src/constants/channel.constants.js index e7b25399..ef1ad8ec 100644 --- a/web/air/src/constants/channel.constants.js +++ b/web/air/src/constants/channel.constants.js @@ -22,6 +22,7 @@ export const CHANNEL_OPTIONS = [ { key: 31, text: '零一万物', value: 31, color: 'green' }, { key: 32, text: '阶跃星辰', value: 32, color: 'blue' }, { key: 34, text: 'Coze', value: 34, color: 'blue' }, + { key: 47, text: 'CozeV3', value: 47, color: 'blue' }, { key: 35, text: 'Cohere', value: 35, color: 'blue' }, { key: 36, text: 'DeepSeek', value: 36, color: 'black' }, { key: 37, text: 'Cloudflare', value: 37, color: 'orange' }, diff --git a/web/berry/src/constants/ChannelConstants.js b/web/berry/src/constants/ChannelConstants.js index 375adcd9..da3f6056 100644 --- a/web/berry/src/constants/ChannelConstants.js +++ b/web/berry/src/constants/ChannelConstants.js @@ -137,6 +137,12 @@ export const CHANNEL_OPTIONS = { value: 34, color: 'primary' }, + 47: { + key: 47, + text: 'CozeV3', + value: 47, + color: 'primary' + }, 35: { key: 35, text: 'Cohere', @@ -185,7 +191,7 @@ export const CHANNEL_OPTIONS = { value: 45, color: 'primary' }, - 45: { + 46: { key: 46, text: 'Replicate', value: 46, diff --git a/web/berry/src/views/Channel/type/Config.js b/web/berry/src/views/Channel/type/Config.js index 67b90733..4a05692d 100644 --- a/web/berry/src/views/Channel/type/Config.js +++ b/web/berry/src/views/Channel/type/Config.js @@ -206,6 +206,20 @@ const typeConfig = { }, modelGroup: 'Coze' }, + 47: { + inputLabel: { + config: { + user_id: 'User ID' + } + }, + prompt: { + models: '对于 CozeV3 而言,模型名称即 Bot ID,你可以添加一个前缀 `bot-`,例如:`bot-123456`', + config: { + user_id: '生成该密钥的用户 ID' + } + }, + modelGroup: 'CozeV3' + }, 42: { inputLabel: { key: '', diff --git a/web/default/src/constants/channel.constants.js b/web/default/src/constants/channel.constants.js index 61425508..aa31fb37 100644 --- a/web/default/src/constants/channel.constants.js +++ b/web/default/src/constants/channel.constants.js @@ -22,6 +22,7 @@ export const CHANNEL_OPTIONS = [ { key: 31, text: '零一万物', value: 31, color: 'green' }, { key: 32, text: '阶跃星辰', value: 32, color: 'blue' }, { key: 34, text: 'Coze', value: 34, color: 'blue' }, + { key: 47, text: 'CozeV3', value: 47, color: 'blue' }, { key: 35, text: 'Cohere', value: 35, color: 'blue' }, { key: 36, text: 'DeepSeek', value: 36, color: 'black' }, { key: 37, text: 'Cloudflare', value: 37, color: 'orange' },