Compare commits

...

18 Commits

Author SHA1 Message Date
CaIon
79d7758617 充值改为保留两位小数 2023-12-09 22:56:24 +08:00
CaIon
5240971b4a update docker-compose.yml 2023-12-09 17:37:43 +08:00
CaIon
00af47566f 修复渠道管理无法点击分页的bug 2023-12-08 19:39:33 +08:00
CaIon
9cd3cd3caa 适配渠道管理移动端 2023-12-08 19:28:04 +08:00
CaIon
b301be7fc0 修复渠道余额显示问题 2023-12-08 18:36:06 +08:00
CaIon
aa07ada0f4 fix pgsql 2023-12-08 13:15:59 +08:00
CaIon
1a7a10c3d4 fix gpt-4-1106-preview count image token 2023-12-08 12:54:29 +08:00
CaIon
821645e5da update README.md 2023-12-07 19:20:04 +08:00
Calcium-Ion
e095900d88 Merge pull request #20 from Calcium-Ion/optimize/hign--cpu
fix: 修复客户端中断请求,计算补全阻塞问题
2023-12-07 17:11:57 +08:00
CaIon
143e279214 Merge remote-tracking branch 'public/main' into latest 2023-12-07 16:58:37 +08:00
CaIon
3ee4136256 消费日志添加用户调用前余额 2023-12-07 16:58:24 +08:00
Calcium-Ion
2ced102a57 Merge pull request #22 from Happy-clo/pr
docs: update
2023-12-06 12:50:45 +08:00
Happy
2c187fde6e docs: update 2023-12-06 00:27:08 +08:00
CaIon
0d469804e3 修复兑换码复制bug 2023-12-06 00:19:00 +08:00
CaIon
655722641b 修复渠道管理没有测试按钮的bug 2023-12-05 21:26:27 +08:00
Xyfacai
f712b73c18 fix: 如果还有数据,等待一会 2023-11-30 20:30:29 +08:00
Xyfacai
ed22a202f7 fix: 如果还有数据,等待一会 2023-11-30 20:28:57 +08:00
Xyfacai
6680f6d83a fix: 修复客户端中断请求,计算补全阻塞问题 2023-11-28 22:02:09 +08:00
16 changed files with 121 additions and 73 deletions

View File

@@ -1,17 +1,17 @@
# Neko API
> **Note**
> [!NOTE]
> 本项目为开源项目,在[One API](https://github.com/songquanpeng/one-api)的基础上进行二次开发,感谢原作者的无私奉献。
> 使用者必须在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途。
> **Warning**
> [!WARNING]
> 本项目为个人学习使用,不保证稳定性,且不提供任何技术支持,使用者必须在遵循 OpenAI 的使用条款以及法律法规的情况下使用,不得用于非法用途。
> 根据[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务。
> **Note**
> 最新版Docker镜像 calciumion/neko-api:latest
> [!NOTE]
> 最新版Docker镜像 calciumion/new-api:latest
> 更新指令 docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR
## 此分叉版本的主要变更

View File

@@ -39,7 +39,12 @@ func DecodeUrlImageData(imageUrl string) (image.Config, error) {
}
// 限制读取的字节数,防止下载整个图片
limitReader := io.LimitReader(response.Body, 8192)
limitReader := io.LimitReader(response.Body, 1024*20)
//data, err := io.ReadAll(limitReader)
//if err != nil {
// log.Fatal(err)
//}
//log.Printf("%x", data)
config, err := getImageConfig(limitReader)
response.Body.Close()
return config, err

View File

@@ -162,7 +162,7 @@ func relayAudioHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode
if quota != 0 {
tokenName := c.GetString("token_name")
logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f", modelRatio, groupRatio)
model.RecordConsumeLog(ctx, userId, channelId, promptTokens, 0, audioRequest.Model, tokenName, quota, logContent, tokenId)
model.RecordConsumeLog(ctx, userId, channelId, promptTokens, 0, audioRequest.Model, tokenName, quota, logContent, tokenId, userQuota)
model.UpdateUserUsedQuotaAndRequestCount(userId, quota)
channelId := c.GetInt("channel_id")
model.UpdateChannelUsedQuota(channelId, quota)

View File

@@ -169,7 +169,7 @@ func relayImageHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode
if quota != 0 {
tokenName := c.GetString("token_name")
logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f", modelRatio, groupRatio)
model.RecordConsumeLog(ctx, userId, channelId, 0, 0, imageRequest.Model, tokenName, quota, logContent, tokenId)
model.RecordConsumeLog(ctx, userId, channelId, 0, 0, imageRequest.Model, tokenName, quota, logContent, tokenId, userQuota)
model.UpdateUserUsedQuotaAndRequestCount(userId, quota)
channelId := c.GetInt("channel_id")
model.UpdateChannelUsedQuota(channelId, quota)

View File

@@ -370,7 +370,7 @@ func relayMidjourneySubmit(c *gin.Context, relayMode int) *MidjourneyResponse {
if quota != 0 {
tokenName := c.GetString("token_name")
logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f", modelRatio, groupRatio)
model.RecordConsumeLog(ctx, userId, channelId, 0, 0, imageModel, tokenName, quota, logContent, tokenId)
model.RecordConsumeLog(ctx, userId, channelId, 0, 0, imageModel, tokenName, quota, logContent, tokenId, userQuota)
model.UpdateUserUsedQuotaAndRequestCount(userId, quota)
channelId := c.GetInt("channel_id")
model.UpdateChannelUsedQuota(channelId, quota)

View File

@@ -9,10 +9,12 @@ import (
"net/http"
"one-api/common"
"strings"
"sync"
"time"
)
func openaiStreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*OpenAIErrorWithStatusCode, string) {
responseText := ""
var responseTextBuilder strings.Builder
scanner := bufio.NewScanner(resp.Body)
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
@@ -26,9 +28,16 @@ func openaiStreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*O
}
return 0, nil, nil
})
dataChan := make(chan string)
stopChan := make(chan bool)
dataChan := make(chan string, 5)
stopChan := make(chan bool, 2)
defer close(stopChan)
defer close(dataChan)
var wg sync.WaitGroup
go func() {
wg.Add(1)
defer wg.Done()
var streamItems []string
for scanner.Scan() {
data := scanner.Text()
if len(data) < 6 { // ignore blank line or wrong format
@@ -40,29 +49,39 @@ func openaiStreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*O
dataChan <- data
data = data[6:]
if !strings.HasPrefix(data, "[DONE]") {
switch relayMode {
case RelayModeChatCompletions:
var streamResponse ChatCompletionsStreamResponseSimple
err := json.Unmarshal(common.StringToByteSlice(data), &streamResponse)
if err != nil {
common.SysError("error unmarshalling stream response: " + err.Error())
continue // just ignore the error
}
for _, choice := range streamResponse.Choices {
responseText += choice.Delta.Content
}
case RelayModeCompletions:
var streamResponse CompletionsStreamResponse
err := json.Unmarshal(common.StringToByteSlice(data), &streamResponse)
if err != nil {
common.SysError("error unmarshalling stream response: " + err.Error())
continue
}
for _, choice := range streamResponse.Choices {
responseText += choice.Text
}
streamItems = append(streamItems, data)
}
}
streamResp := "[" + strings.Join(streamItems, ",") + "]"
switch relayMode {
case RelayModeChatCompletions:
var streamResponses []ChatCompletionsStreamResponseSimple
err := json.Unmarshal(common.StringToByteSlice(streamResp), &streamResponses)
if err != nil {
common.SysError("error unmarshalling stream response: " + err.Error())
return // just ignore the error
}
for _, streamResponse := range streamResponses {
for _, choice := range streamResponse.Choices {
responseTextBuilder.WriteString(choice.Delta.Content)
}
}
case RelayModeCompletions:
var streamResponses []CompletionsStreamResponse
err := json.Unmarshal(common.StringToByteSlice(streamResp), &streamResponses)
if err != nil {
common.SysError("error unmarshalling stream response: " + err.Error())
return // just ignore the error
}
for _, streamResponse := range streamResponses {
for _, choice := range streamResponse.Choices {
responseTextBuilder.WriteString(choice.Text)
}
}
}
if len(dataChan) > 0 {
// wait data out
time.Sleep(2 * time.Second)
}
stopChan <- true
}()
@@ -85,7 +104,8 @@ func openaiStreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*O
if err != nil {
return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), ""
}
return nil, responseText
wg.Wait()
return nil, responseTextBuilder.String()
}
func openaiHandler(c *gin.Context, resp *http.Response, promptTokens int, model string) (*OpenAIErrorWithStatusCode, *Usage) {

View File

@@ -446,7 +446,7 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
// record all the consume log even if quota is 0
useTimeSeconds := time.Now().Unix() - startTime.Unix()
logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f,用时 %d秒", modelRatio, groupRatio, useTimeSeconds)
model.RecordConsumeLog(ctx, userId, channelId, promptTokens, completionTokens, textRequest.Model, tokenName, quota, logContent, tokenId)
model.RecordConsumeLog(ctx, userId, channelId, promptTokens, completionTokens, textRequest.Model, tokenName, quota, logContent, tokenId, userQuota)
model.UpdateUserUsedQuotaAndRequestCount(userId, quota)
model.UpdateChannelUsedQuota(channelId, quota)
//if quota != 0 {

View File

@@ -98,7 +98,7 @@ func RequestEpay(c *gin.Context) {
topUp := &model.TopUp{
UserId: id,
Amount: req.Amount,
Money: int(amount),
Money: payMoney,
TradeNo: "A" + tradeNo,
CreateTime: time.Now().Unix(),
Status: "pending",
@@ -175,5 +175,6 @@ func RequestAmount(c *gin.Context) {
}
id := c.GetInt("id")
user, _ := model.GetUserById(id, false)
c.JSON(200, gin.H{"message": "success", "data": GetAmount(float64(req.Amount), *user)})
payMoney := GetAmount(float64(req.Amount), *user)
c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
}

View File

@@ -2,7 +2,7 @@ version: '3.4'
services:
one-api:
image: calciumion/neko-api:main
image: calciumion/new-api:latest
container_name: one-api
restart: always
command: --log-dir /app/logs

View File

@@ -14,10 +14,14 @@ type Ability struct {
}
func GetGroupModels(group string) []string {
var models []string
// Find distinct models
DB.Table("abilities").Where("`group` = ? and enabled = ?", group, true).Distinct("model").Pluck("model", &models)
return models
var models []string
// Find distinct models
groupCol := "`group`"
if common.UsingPostgreSQL {
groupCol = `"group"`
}
DB.Table("abilities").Where(groupCol+" = ? and enabled = ?", group, true).Distinct("model").Pluck("model", &models)
return models
}
func GetRandomSatisfiedChannel(group string, model string) (*Channel, error) {

View File

@@ -54,8 +54,8 @@ 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, tokenId int) {
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))
func RecordConsumeLog(ctx context.Context, userId int, channelId int, promptTokens int, completionTokens int, modelName string, tokenName string, quota int, content string, tokenId int, userQuota int) {
common.LogInfo(ctx, fmt.Sprintf("record consume log: userId=%d, 用户调用前余额=%d, channelId=%d, promptTokens=%d, completionTokens=%d, modelName=%s, tokenName=%s, quota=%d, content=%s", userId, userQuota, channelId, promptTokens, completionTokens, modelName, tokenName, quota, content))
if !common.LogConsumeEnabled {
return
}

View File

@@ -219,6 +219,17 @@ func PostConsumeTokenQuota(tokenId int, userQuota int, quota int, preConsumedQuo
return err
}
if !token.UnlimitedQuota {
if quota > 0 {
err = DecreaseTokenQuota(tokenId, quota)
} else {
err = IncreaseTokenQuota(tokenId, -quota)
}
if err != nil {
return err
}
}
if sendEmail {
if (quota + preConsumedQuota) != 0 {
quotaTooLow := userQuota >= common.QuotaRemindThreshold && userQuota-(quota+preConsumedQuota) < common.QuotaRemindThreshold
@@ -247,15 +258,5 @@ func PostConsumeTokenQuota(tokenId int, userQuota int, quota int, preConsumedQuo
}
}
if !token.UnlimitedQuota {
if quota > 0 {
err = DecreaseTokenQuota(tokenId, quota)
} else {
err = IncreaseTokenQuota(tokenId, -quota)
}
if err != nil {
return err
}
}
return nil
}

View File

@@ -1,13 +1,13 @@
package model
type TopUp struct {
Id int `json:"id"`
UserId int `json:"user_id" gorm:"index"`
Amount int `json:"amount"`
Money int `json:"money"`
TradeNo string `json:"trade_no"`
CreateTime int64 `json:"create_time"`
Status string `json:"status"`
Id int `json:"id"`
UserId int `json:"user_id" gorm:"index"`
Amount int `json:"amount"`
Money float64 `json:"money"`
TradeNo string `json:"trade_no"`
CreateTime int64 `json:"create_time"`
Status string `json:"status"`
}
func (topUp *TopUp) Insert() error {

View File

@@ -1,7 +1,16 @@
import React, {useEffect, useState} from 'react';
import {Input, Label, Message, Popup} from 'semantic-ui-react';
import {Link} from 'react-router-dom';
import {API, setPromptShown, shouldShowPrompt, showError, showInfo, showSuccess, timestamp2string} from '../helpers';
import {
API,
isMobile,
setPromptShown,
shouldShowPrompt,
showError,
showInfo,
showSuccess,
timestamp2string
} from '../helpers';
import {CHANNEL_OPTIONS, ITEMS_PER_PAGE} from '../constants';
import {renderGroup, renderNumber, renderQuota, renderQuotaWithPrompt} from '../helpers/render';
@@ -134,7 +143,7 @@ const ChannelsTable = () => {
<Tag color='white' type='ghost' size='large'>{renderQuota(record.used_quota)}</Tag>
</Tooltip>
<Tooltip content={'剩余额度,点击更新'}>
<Tag color='white' type='ghost' size='large' onClick={() => {updateChannelBalance(record)}}>{renderQuota(record.balance)}</Tag>
<Tag color='white' type='ghost' size='large' onClick={() => {updateChannelBalance(record)}}>${record.balance}</Tag>
</Tooltip>
</Space>
</div>
@@ -165,6 +174,7 @@ const ChannelsTable = () => {
dataIndex: 'operate',
render: (text, record, index) => (
<div>
<Button theme='light' type='primary' style={{marginRight: 1}} onClick={()=>testChannel(record)}>测试</Button>
<Popconfirm
title="确定是否要删除此渠道?"
content="此修改将不可逆"
@@ -415,16 +425,15 @@ const ChannelsTable = () => {
setSearching(false);
};
const testChannel = async (id, name, idx) => {
const res = await API.get(`/api/channel/test/${id}/`);
const testChannel = async (record) => {
const res = await API.get(`/api/channel/test/${record.id}/`);
const {success, message, time} = res.data;
if (success) {
let newChannels = [...channels];
let realIdx = (activePage - 1) * pageSize + idx;
newChannels[realIdx].response_time = time * 1000;
newChannels[realIdx].test_time = Date.now() / 1000;
record.response_time = time * 1000;
record.test_time = Date.now() / 1000;
setChannels(newChannels);
showInfo(`通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。`);
showInfo(`通道 ${record.name} 测试成功,耗时 ${time.toFixed(2)} 秒。`);
} else {
showError(message);
}
@@ -593,13 +602,14 @@ const ChannelsTable = () => {
total: channelCount,
pageSizeOpts: [10, 20, 50, 100],
showSizeChanger: true,
formatPageText:(page) => '',
onPageSizeChange: (size) => {
handlePageSizeChange(size).then()
},
onPageChange: handlePageChange,
}} loading={loading} onRow={handleRow}/>
<div style={{display: 'flex'}}>
<Space>
<div style={{display: isMobile()?'':'flex', marginTop: isMobile()?0:-45, zIndex: 999, position: 'relative', pointerEvents: 'none'}}>
<Space style={{pointerEvents: 'auto'}}>
<Button theme='light' type='primary' style={{marginRight: 8}} onClick={
() => {
setEditingChannel({
@@ -612,6 +622,7 @@ const ChannelsTable = () => {
title="确定?"
okType={'warning'}
onConfirm={testAllChannels}
position={isMobile()?'top':''}
>
<Button theme='light' type='warning' style={{marginRight: 8}}>测试所有已启用通道</Button>
</Popconfirm>
@@ -633,6 +644,9 @@ const ChannelsTable = () => {
<Button theme='light' type='primary' style={{marginRight: 8}} onClick={refresh}>刷新</Button>
</Space>
{/*<div style={{width: '100%', pointerEvents: 'none', position: 'absolute'}}>*/}
{/*</div>*/}
</div>
</>
);

View File

@@ -173,9 +173,9 @@ const RedemptionsTable = () => {
// }
const setRedemptionFormat = (redeptions) => {
for (let i = 0; i < redeptions.length; i++) {
redeptions[i].key = '' + redeptions[i].id;
}
// for (let i = 0; i < redeptions.length; i++) {
// redeptions[i].key = '' + redeptions[i].id;
// }
// data.key = '' + data.id
setRedemptions(redeptions);
if (redeptions.length >= (activePage) * ITEMS_PER_PAGE) {

View File

@@ -27,6 +27,9 @@ body {
.semi-table-tbody>.semi-table-row {
border-bottom: 1px solid rgba(0,0,0,.1);
}
.semi-space {
display: block!important;
}
}
.semi-layout {