feat: able to query test log

This commit is contained in:
JustSong 2025-01-31 21:23:12 +08:00
parent 4f68f3e1b3
commit fa2a772731
6 changed files with 377 additions and 184 deletions

View File

@ -410,6 +410,7 @@ graph LR
27. `INITIAL_ROOT_TOKEN`:如果设置了该值,则在系统首次启动时会自动创建一个值为该环境变量值的 root 用户令牌。
28. `INITIAL_ROOT_ACCESS_TOKEN`:如果设置了该值,则在系统首次启动时会自动创建一个值为该环境变量的 root 用户创建系统管理令牌。
29. `ENFORCE_INCLUDE_USAGE`:是否强制在 stream 模型下返回 usage默认不开启可选值为 `true``false`
30. `TEST_PROMPT`:测试模型时的用户 prompt默认为 `Print your model name exactly and do not output without any other text.`
### 命令行参数
1. `--port <port_number>`: 指定服务器监听的端口号,默认为 `3000`

View File

@ -1,13 +1,14 @@
package config
import (
"github.com/songquanpeng/one-api/common/env"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/songquanpeng/one-api/common/env"
"github.com/google/uuid"
)
@ -162,3 +163,4 @@ var UserContentRequestProxy = env.String("USER_CONTENT_REQUEST_PROXY", "")
var UserContentRequestTimeout = env.Int("USER_CONTENT_REQUEST_TIMEOUT", 30)
var EnforceIncludeUsage = env.Bool("ENFORCE_INCLUDE_USAGE", false)
var TestPrompt = env.String("TEST_PROMPT", "Print your model name exactly and do not output without any other text.")

View File

@ -2,6 +2,7 @@ package controller
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@ -15,14 +16,17 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/config"
"github.com/songquanpeng/one-api/common/ctxkey"
"github.com/songquanpeng/one-api/common/helper"
"github.com/songquanpeng/one-api/common/logger"
"github.com/songquanpeng/one-api/common/message"
"github.com/songquanpeng/one-api/middleware"
"github.com/songquanpeng/one-api/model"
"github.com/songquanpeng/one-api/monitor"
relay "github.com/songquanpeng/one-api/relay"
"github.com/songquanpeng/one-api/relay"
"github.com/songquanpeng/one-api/relay/adaptor/openai"
"github.com/songquanpeng/one-api/relay/channeltype"
"github.com/songquanpeng/one-api/relay/controller"
"github.com/songquanpeng/one-api/relay/meta"
@ -35,18 +39,34 @@ func buildTestRequest(model string) *relaymodel.GeneralOpenAIRequest {
model = "gpt-3.5-turbo"
}
testRequest := &relaymodel.GeneralOpenAIRequest{
MaxTokens: 2,
Model: model,
}
testMessage := relaymodel.Message{
Role: "user",
Content: "hi",
Content: config.TestPrompt,
}
testRequest.Messages = append(testRequest.Messages, testMessage)
return testRequest
}
func testChannel(channel *model.Channel, request *relaymodel.GeneralOpenAIRequest) (err error, openaiErr *relaymodel.Error) {
func parseTestResponse(resp string) (*openai.TextResponse, string, error) {
var response openai.TextResponse
err := json.Unmarshal([]byte(resp), &response)
if err != nil {
return nil, "", err
}
if len(response.Choices) == 0 {
return nil, "", errors.New("response has no choices")
}
stringContent, ok := response.Choices[0].Content.(string)
if !ok {
return nil, "", errors.New("response content is not string")
}
return &response, stringContent, nil
}
func testChannel(ctx context.Context, channel *model.Channel, request *relaymodel.GeneralOpenAIRequest) (responseMessage string, err error, openaiErr *relaymodel.Error) {
startTime := time.Now()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = &http.Request{
@ -66,7 +86,7 @@ func testChannel(channel *model.Channel, request *relaymodel.GeneralOpenAIReques
apiType := channeltype.ToAPIType(channel.Type)
adaptor := relay.GetAdaptor(apiType)
if adaptor == nil {
return fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), nil
return "", fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), nil
}
adaptor.Init(meta)
modelName := request.Model
@ -84,41 +104,69 @@ func testChannel(channel *model.Channel, request *relaymodel.GeneralOpenAIReques
request.Model = modelName
convertedRequest, err := adaptor.ConvertRequest(c, relaymode.ChatCompletions, request)
if err != nil {
return err, nil
return "", err, nil
}
jsonData, err := json.Marshal(convertedRequest)
if err != nil {
return err, nil
return "", err, nil
}
defer func() {
logContent := fmt.Sprintf("渠道 %s 测试成功,响应:%s", channel.Name, responseMessage)
if err != nil || openaiErr != nil {
errorMessage := ""
if err != nil {
errorMessage = err.Error()
} else {
errorMessage = openaiErr.Message
}
logContent = fmt.Sprintf("渠道 %s 测试失败,错误:%s", channel.Name, errorMessage)
}
go model.RecordTestLog(ctx, &model.Log{
ChannelId: channel.Id,
ModelName: modelName,
Content: logContent,
ElapsedTime: helper.CalcElapsedTime(startTime),
})
}()
logger.SysLog(string(jsonData))
requestBody := bytes.NewBuffer(jsonData)
c.Request.Body = io.NopCloser(requestBody)
resp, err := adaptor.DoRequest(c, meta, requestBody)
if err != nil {
return err, nil
return "", err, nil
}
if resp != nil && resp.StatusCode != http.StatusOK {
err := controller.RelayErrorHandler(resp)
return fmt.Errorf("status code %d: %s", resp.StatusCode, err.Error.Message), &err.Error
errorMessage := err.Error.Message
if errorMessage != "" {
errorMessage = ", error message: " + errorMessage
}
return "", fmt.Errorf("http status code: %d%s", resp.StatusCode, errorMessage), &err.Error
}
usage, respErr := adaptor.DoResponse(c, resp, meta)
if respErr != nil {
return fmt.Errorf("%s", respErr.Error.Message), &respErr.Error
return "", fmt.Errorf("%s", respErr.Error.Message), &respErr.Error
}
if usage == nil {
return errors.New("usage is nil"), nil
return "", errors.New("usage is nil"), nil
}
rawResponse := w.Body.String()
_, responseMessage, err = parseTestResponse(rawResponse)
if err != nil {
return "", err, nil
}
result := w.Result()
// print result.Body
respBody, err := io.ReadAll(result.Body)
if err != nil {
return err, nil
return "", err, nil
}
logger.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
return nil, nil
return responseMessage, nil, nil
}
func TestChannel(c *gin.Context) {
ctx := c.Request.Context()
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
@ -135,10 +183,10 @@ func TestChannel(c *gin.Context) {
})
return
}
model := c.Query("model")
testRequest := buildTestRequest(model)
modelName := c.Query("model")
testRequest := buildTestRequest(modelName)
tik := time.Now()
err, _ = testChannel(channel, testRequest)
responseMessage, err, _ := testChannel(ctx, channel, testRequest)
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
if err != nil {
@ -151,15 +199,15 @@ func TestChannel(c *gin.Context) {
"success": false,
"message": err.Error(),
"time": consumedTime,
"model": model,
"modelName": modelName,
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"message": responseMessage,
"time": consumedTime,
"model": model,
"modelName": modelName,
})
return
}
@ -167,7 +215,7 @@ func TestChannel(c *gin.Context) {
var testAllChannelsLock sync.Mutex
var testAllChannelsRunning bool = false
func testChannels(notify bool, scope string) error {
func testChannels(ctx context.Context, notify bool, scope string) error {
if config.RootUserEmail == "" {
config.RootUserEmail = model.GetRootUserEmail()
}
@ -191,7 +239,7 @@ func testChannels(notify bool, scope string) error {
isChannelEnabled := channel.Status == model.ChannelStatusEnabled
tik := time.Now()
testRequest := buildTestRequest("")
err, openaiErr := testChannel(channel, testRequest)
_, err, openaiErr := testChannel(ctx, channel, testRequest)
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
if isChannelEnabled && milliseconds > disableThreshold {
@ -225,11 +273,12 @@ func testChannels(notify bool, scope string) error {
}
func TestChannels(c *gin.Context) {
ctx := c.Request.Context()
scope := c.Query("scope")
if scope == "" {
scope = "all"
}
err := testChannels(true, scope)
err := testChannels(ctx, true, scope)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@ -245,10 +294,11 @@ func TestChannels(c *gin.Context) {
}
func AutomaticallyTestChannels(frequency int) {
ctx := context.Background()
for {
time.Sleep(time.Duration(frequency) * time.Minute)
logger.SysLog("testing all channels")
_ = testChannels(false, "all")
_ = testChannels(ctx, false, "all")
logger.SysLog("channel test finished")
}
}

View File

@ -37,6 +37,7 @@ const (
LogTypeConsume
LogTypeManage
LogTypeSystem
LogTypeTest
)
func recordLogHelper(ctx context.Context, log *Log) {
@ -86,6 +87,12 @@ func RecordConsumeLog(ctx context.Context, log *Log) {
recordLogHelper(ctx, log)
}
func RecordTestLog(ctx context.Context, log *Log) {
log.CreatedAt = helper.GetTimestamp()
log.Type = LogTypeTest
recordLogHelper(ctx, log)
}
func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int) (logs []*Log, err error) {
var tx *gorm.DB
if logType == LogTypeUnknown {

View File

@ -1,5 +1,15 @@
import React, { useEffect, useState } from 'react';
import { Button, Dropdown, Form, Input, Label, Message, Pagination, Popup, Table } from 'semantic-ui-react';
import {
Button,
Dropdown,
Form,
Input,
Label,
Message,
Pagination,
Popup,
Table,
} from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import {
API,
@ -9,31 +19,31 @@ import {
showError,
showInfo,
showSuccess,
timestamp2string
timestamp2string,
} from '../helpers';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
import { renderGroup, renderNumber } from '../helpers/render';
function renderTimestamp(timestamp) {
return (
<>
{timestamp2string(timestamp)}
</>
);
return <>{timestamp2string(timestamp)}</>;
}
let type2label = undefined;
function renderType(type) {
if (!type2label) {
type2label = new Map;
type2label = new Map();
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
}
type2label[0] = { value: 0, text: '未知类型', color: 'grey' };
}
return <Label basic color={type2label[type]?.color}>{type2label[type] ? type2label[type].text : type}</Label>;
return (
<Label basic color={type2label[type]?.color}>
{type2label[type] ? type2label[type].text : type}
</Label>
);
}
function renderBalance(type, balance) {
@ -62,10 +72,10 @@ function renderBalance(type, balance) {
}
function isShowDetail() {
return localStorage.getItem("show_detail") === "true";
return localStorage.getItem('show_detail') === 'true';
}
const promptID = "detail"
const promptID = 'detail';
const ChannelsTable = () => {
const [channels, setChannels] = useState([]);
@ -84,7 +94,7 @@ const ChannelsTable = () => {
let localChannels = data.map((channel) => {
if (channel.models === '') {
channel.models = [];
channel.test_model = "";
channel.test_model = '';
} else {
channel.models = channel.models.split(',');
if (channel.models.length > 0) {
@ -95,9 +105,9 @@ const ChannelsTable = () => {
key: model,
text: model,
value: model,
}
})
console.log('channel', channel)
};
});
console.log('channel', channel);
}
return channel;
});
@ -105,7 +115,11 @@ const ChannelsTable = () => {
setChannels(localChannels);
} else {
let newChannels = [...channels];
newChannels.splice(startIdx * ITEMS_PER_PAGE, data.length, ...localChannels);
newChannels.splice(
startIdx * ITEMS_PER_PAGE,
data.length,
...localChannels
);
setChannels(newChannels);
}
} else {
@ -131,8 +145,8 @@ const ChannelsTable = () => {
const toggleShowDetail = () => {
setShowDetail(!showDetail);
localStorage.setItem("show_detail", (!showDetail).toString());
}
localStorage.setItem('show_detail', (!showDetail).toString());
};
useEffect(() => {
loadChannels(0)
@ -196,13 +210,19 @@ const ChannelsTable = () => {
const renderStatus = (status) => {
switch (status) {
case 1:
return <Label basic color='green'>已启用</Label>;
return (
<Label basic color='green'>
已启用
</Label>
);
case 2:
return (
<Popup
trigger={<Label basic color='red'>
trigger={
<Label basic color='red'>
已禁用
</Label>}
</Label>
}
content='本渠道被手动禁用'
basic
/>
@ -210,9 +230,11 @@ const ChannelsTable = () => {
case 3:
return (
<Popup
trigger={<Label basic color='yellow'>
trigger={
<Label basic color='yellow'>
已禁用
</Label>}
</Label>
}
content='本渠道被程序自动禁用'
basic
/>
@ -230,15 +252,35 @@ const ChannelsTable = () => {
let time = responseTime / 1000;
time = time.toFixed(2) + ' 秒';
if (responseTime === 0) {
return <Label basic color='grey'>未测试</Label>;
return (
<Label basic color='grey'>
未测试
</Label>
);
} else if (responseTime <= 1000) {
return <Label basic color='green'>{time}</Label>;
return (
<Label basic color='green'>
{time}
</Label>
);
} else if (responseTime <= 3000) {
return <Label basic color='olive'>{time}</Label>;
return (
<Label basic color='olive'>
{time}
</Label>
);
} else if (responseTime <= 5000) {
return <Label basic color='yellow'>{time}</Label>;
return (
<Label basic color='yellow'>
{time}
</Label>
);
} else {
return <Label basic color='red'>{time}</Label>;
return (
<Label basic color='red'>
{time}
</Label>
);
}
};
@ -277,7 +319,11 @@ const ChannelsTable = () => {
newChannels[realIdx].response_time = time * 1000;
newChannels[realIdx].test_time = Date.now() / 1000;
setChannels(newChannels);
showInfo(`渠道 ${name} 测试成功,模型 ${model},耗时 ${time.toFixed(2)} 秒。`);
showInfo(
`渠道 ${name} 测试成功,模型 ${model},耗时 ${time.toFixed(
2
)} 模型输出${message}`
);
} else {
showError(message);
}
@ -360,7 +406,6 @@ const ChannelsTable = () => {
setLoading(false);
};
return (
<>
<Form onSubmit={searchChannels}>
@ -374,20 +419,22 @@ const ChannelsTable = () => {
onChange={handleKeywordChange}
/>
</Form>
{
showPrompt && (
<Message onDismiss={() => {
{showPrompt && (
<Message
onDismiss={() => {
setShowPrompt(false);
setPromptShown(promptID);
}}>
OpenAI 渠道已经不再支持通过 key 获取余额因此余额显示为 0对于支持的渠道类型请点击余额进行刷新
<br/>
渠道测试仅支持 chat 模型优先使用 gpt-3.5-turbo如果该模型不可用则使用你所配置的模型列表中的第一个模型
<br/>
}}
>
OpenAI 渠道已经不再支持通过 key 获取余额因此余额显示为
0对于支持的渠道类型请点击余额进行刷新
<br />
渠道测试仅支持 chat 模型优先使用
gpt-3.5-turbo如果该模型不可用则使用你所配置的模型列表中的第一个模型
<br />
点击下方详情按钮可以显示余额以及设置额外的测试模型
</Message>
)
}
)}
<Table basic compact size='small'>
<Table.Header>
<Table.Row>
@ -478,7 +525,11 @@ const ChannelsTable = () => {
<Table.Cell>{renderStatus(channel.status)}</Table.Cell>
<Table.Cell>
<Popup
content={channel.test_time ? renderTimestamp(channel.test_time) : '未测试'}
content={
channel.test_time
? renderTimestamp(channel.test_time)
: '未测试'
}
key={channel.id}
trigger={renderResponseTime(channel.response_time)}
basic
@ -486,27 +537,38 @@ const ChannelsTable = () => {
</Table.Cell>
<Table.Cell hidden={!showDetail}>
<Popup
trigger={<span onClick={() => {
trigger={
<span
onClick={() => {
updateChannelBalance(channel.id, channel.name, idx);
}} style={{ cursor: 'pointer' }}>
}}
style={{ cursor: 'pointer' }}
>
{renderBalance(channel.type, channel.balance)}
</span>}
</span>
}
content='点击更新'
basic
/>
</Table.Cell>
<Table.Cell>
<Popup
trigger={<Input type='number' defaultValue={channel.priority} onBlur={(event) => {
trigger={
<Input
type='number'
defaultValue={channel.priority}
onBlur={(event) => {
manageChannel(
channel.id,
'priority',
idx,
event.target.value
);
}}>
}}
>
<input style={{ maxWidth: '60px' }} />
</Input>}
</Input>
}
content='渠道选择优先级,越高越优先'
basic
/>
@ -528,7 +590,12 @@ const ChannelsTable = () => {
size={'small'}
positive
onClick={() => {
testChannel(channel.id, channel.name, idx, channel.test_model);
testChannel(
channel.id,
channel.name,
idx,
channel.test_model
);
}}
>
测试
@ -590,14 +657,31 @@ const ChannelsTable = () => {
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan={showDetail ? "10" : "8"}>
<Button size='small' as={Link} to='/channel/add' loading={loading}>
<Table.HeaderCell colSpan={showDetail ? '10' : '8'}>
<Button
size='small'
as={Link}
to='/channel/add'
loading={loading}
>
添加新的渠道
</Button>
<Button size='small' loading={loading} onClick={()=>{testChannels("all")}}>
<Button
size='small'
loading={loading}
onClick={() => {
testChannels('all');
}}
>
测试所有渠道
</Button>
<Button size='small' loading={loading} onClick={()=>{testChannels("disabled")}}>
<Button
size='small'
loading={loading}
onClick={() => {
testChannels('disabled');
}}
>
测试禁用渠道
</Button>
{/*<Button size='small' onClick={updateAllChannelsBalance}*/}
@ -612,7 +696,12 @@ const ChannelsTable = () => {
flowing
hoverable
>
<Button size='small' loading={loading} negative onClick={deleteAllDisabledChannels}>
<Button
size='small'
loading={loading}
negative
onClick={deleteAllDisabledChannels}
>
确认删除
</Button>
</Popup>
@ -627,8 +716,12 @@ const ChannelsTable = () => {
(channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
}
/>
<Button size='small' onClick={refresh} loading={loading}>刷新</Button>
<Button size='small' onClick={toggleShowDetail}>{showDetail ? "隐藏详情" : "详情"}</Button>
<Button size='small' onClick={refresh} loading={loading}>
刷新
</Button>
<Button size='small' onClick={toggleShowDetail}>
{showDetail ? '隐藏详情' : '详情'}
</Button>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>

View File

@ -21,6 +21,7 @@ import {
import { ITEMS_PER_PAGE } from '../constants';
import { renderColorLabel, renderQuota } from '../helpers/render';
import { Link } from 'react-router-dom';
function renderTimestamp(timestamp, request_id) {
return (
@ -50,6 +51,7 @@ const LOG_OPTIONS = [
{ key: '2', text: '消费', value: 2 },
{ key: '3', text: '管理', value: 3 },
{ key: '4', text: '系统', value: 4 },
{ key: '5', text: '测试', value: 5 },
];
function renderType(type) {
@ -78,6 +80,12 @@ function renderType(type) {
系统
</Label>
);
case 5:
return (
<Label basic color='violet'>
测试
</Label>
);
default:
return (
<Label basic color='black'>
@ -203,6 +211,10 @@ const LogsTable = () => {
setShowStat(!showStat);
};
const showUserTokenQuota = () => {
return logType !== 5;
};
const loadLogs = async (startIdx) => {
let url = '';
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
@ -399,6 +411,26 @@ const LogsTable = () => {
渠道
</Table.HeaderCell>
)}
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('type');
}}
width={1}
>
类型
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('model_name');
}}
width={2}
>
模型
</Table.HeaderCell>
{showUserTokenQuota() && (
<>
{isAdminUser && (
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@ -419,24 +451,6 @@ const LogsTable = () => {
>
令牌
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('type');
}}
width={1}
>
类型
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('model_name');
}}
width={2}
>
模型
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
@ -464,6 +478,8 @@ const LogsTable = () => {
>
额度
</Table.HeaderCell>
</>
)}
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
@ -491,25 +507,46 @@ const LogsTable = () => {
</Table.Cell>
{isAdminUser && (
<Table.Cell>
{log.channel ? <Label basic>{log.channel}</Label> : ''}
{log.channel ? (
<Label
basic
as={Link}
to={`/channel/edit/${log.channel}`}
>
{log.channel}
</Label>
) : (
''
)}
</Table.Cell>
)}
<Table.Cell>{renderType(log.type)}</Table.Cell>
<Table.Cell>
{log.model_name ? renderColorLabel(log.model_name) : ''}
</Table.Cell>
{showUserTokenQuota() && (
<>
{isAdminUser && (
<Table.Cell>
{log.username ? (
<Label basic>{log.username}</Label>
<Label
basic
as={Link}
to={`/user/edit/${log.user_id}`}
>
{log.username}
</Label>
) : (
''
)}
</Table.Cell>
)}
<Table.Cell>
{log.token_name ? renderColorLabel(log.token_name) : ''}
</Table.Cell>
<Table.Cell>{renderType(log.type)}</Table.Cell>
<Table.Cell>
{log.model_name ? renderColorLabel(log.model_name) : ''}
{log.token_name
? renderColorLabel(log.token_name)
: ''}
</Table.Cell>
<Table.Cell>
{log.prompt_tokens ? log.prompt_tokens : ''}
</Table.Cell>
@ -519,6 +556,9 @@ const LogsTable = () => {
<Table.Cell>
{log.quota ? renderQuota(log.quota, 6) : ''}
</Table.Cell>
</>
)}
<Table.Cell>{renderDetail(log)}</Table.Cell>
</Table.Row>
);