Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ service/relayconvert/chat_responses_live_local_test.go
service/openaicompat/chat_responses_live_local_test.go
go.work
go.work.sum
.superpowers/
1 change: 1 addition & 0 deletions controller/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ var auditContentTemplates = map[string]string{
"channel.delete_batch": "Batch deleted ${count} channels",
"channel.delete_disabled": "Deleted all disabled channels (${count})",
"channel.key_view": "Viewed channel key ${name} (ID: ${id})",
"token.key_view": "Viewed token key ${name} (ID: ${id})",
"channel.tag_disable": "Disabled channels with tag ${tag}",
"channel.tag_enable": "Enabled channels with tag ${tag}",
"channel.tag_edit": "Edited channels with tag ${tag}",
Expand Down
51 changes: 26 additions & 25 deletions controller/chat_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,48 +8,44 @@ import (
"github.com/gin-gonic/gin"
)

func AdminGetChatLogs(c *gin.Context) {
func AdminGetChatSessions(c *gin.Context) {
if !model.ChatLogDBEnabled() {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "对话详情库未配置"})
return
}
tokenId, _ := strconv.Atoi(c.Query("token_id"))
userId, _ := strconv.Atoi(c.Query("user_id"))
channelId, _ := strconv.Atoi(c.Query("channel_id"))
modelName := c.Query("model_name")
page, _ := strconv.Atoi(c.Query("page"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))

logs, total, err := model.SearchChatLogs(tokenId, userId, channelId, modelName, "", page, pageSize)
sessions, total, err := model.SearchChatSessions(tokenId, userId, modelName, page, pageSize)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
type chatLogMeta struct {
Id int `json:"id"`
TokenId int `json:"token_id"`
UserId int `json:"user_id"`
ChannelId int `json:"channel_id"`
ModelName string `json:"model_name"`
RequestId string `json:"request_id"`
IsStream bool `json:"is_stream"`
Truncated bool `json:"truncated"`
StatusCode int `json:"status_code"`
UseTime int `json:"use_time"`
CreatedAt int64 `json:"created_at"`
type chatSessionMeta struct {
Id int `json:"id"`
TokenId int `json:"token_id"`
UserId int `json:"user_id"`
ModelName string `json:"model_name"`
TurnCount int `json:"turn_count"`
MessageCount int `json:"message_count"`
CreatedAt int64 `json:"created_at"`
LastActiveAt int64 `json:"last_active_at"`
}
out := make([]chatLogMeta, 0, len(logs))
for _, l := range logs {
out = append(out, chatLogMeta{
Id: l.Id, TokenId: l.TokenId, UserId: l.UserId, ChannelId: l.ChannelId,
ModelName: l.ModelName, RequestId: l.RequestId, IsStream: l.IsStream,
Truncated: l.Truncated, StatusCode: l.StatusCode, UseTime: l.UseTime, CreatedAt: l.CreatedAt,
out := make([]chatSessionMeta, 0, len(sessions))
for _, s := range sessions {
out = append(out, chatSessionMeta{
Id: s.Id, TokenId: s.TokenId, UserId: s.UserId, ModelName: s.ModelName,
TurnCount: s.TurnCount, MessageCount: s.MessageCount,
CreatedAt: s.CreatedAt, LastActiveAt: s.LastActiveAt,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": out, "total": total})
}

func AdminGetChatLogDetail(c *gin.Context) {
func AdminGetChatSessionDetail(c *gin.Context) {
if !model.ChatLogDBEnabled() {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "对话详情库未配置"})
return
Expand All @@ -59,10 +55,15 @@ func AdminGetChatLogDetail(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效 ID"})
return
}
cl, err := model.GetChatLogById(id)
session, err := model.GetChatSessionById(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "记录不存在"})
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "会话不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": cl})
turns, err := model.GetChatTurnsBySessionId(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"session": session, "turns": turns}})
}
148 changes: 115 additions & 33 deletions controller/chat_log_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package controller

import (
"fmt"
"net/http"
"net/http/httptest"
"strconv"
Expand All @@ -16,49 +17,130 @@ import (
func setupChatLogTestDB(t *testing.T) {
t.Helper()
db := setupTokenControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.ChatLog{}))
require.NoError(t, db.AutoMigrate(&model.ChatSession{}, &model.ChatTurn{}))
model.CHATLOG_DB = db
common.SetChatLogDatabaseType(common.DatabaseTypeSQLite)
t.Cleanup(func() { model.CHATLOG_DB = nil })
}

func TestAdminGetChatLogs_ListAndDetail(t *testing.T) {
func serveChatLog(t *testing.T, route string, handler gin.HandlerFunc, url string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
r := gin.New()
r.GET(route, handler)
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, url, nil))
return rec
}

func TestAdminGetChatSessions_ListMetaShape(t *testing.T) {
setupChatLogTestDB(t)
cl := &model.ChatLog{
TokenId: 1, UserId: 1, ChannelId: 5, ModelName: "gpt-4",
RequestId: "req-x", RequestBody: `{"q":1}`, ResponseBody: `{"a":2}`,
s := &model.ChatSession{
TokenId: 1, UserId: 1, ModelName: "gpt-4", System: "secret-system",
TurnCount: 2, MessageCount: 4, PrefixHash: "h1",
}
require.NoError(t, cl.Insert())
require.NoError(t, s.Insert())

rec := serveChatLog(t, "/chat_logs/sessions", AdminGetChatSessions, "/chat_logs/sessions?page=1&page_size=10")
require.Equal(t, http.StatusOK, rec.Code)

authMW := func(role int) gin.HandlerFunc {
return func(c *gin.Context) { c.Set("role", role); c.Next() }
var resp struct {
Success bool `json:"success"`
Total int64 `json:"total"`
Data []map[string]any `json:"data"`
}
require.NoError(t, common.Unmarshal(rec.Body.Bytes(), &resp))
require.True(t, resp.Success)
require.Equal(t, int64(1), resp.Total)
require.Len(t, resp.Data, 1)

// List
rec := httptest.NewRecorder()
r := gin.New()
r.GET("/chat_logs", authMW(common.RoleAdminUser), AdminGetChatLogs)
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/chat_logs?page=1&page_size=10", nil))
meta := resp.Data[0]
for _, key := range []string{"id", "token_id", "user_id", "model_name", "turn_count", "message_count", "created_at", "last_active_at"} {
assert.Contains(t, meta, key)
}
for _, key := range []string{"system", "prefix_hash", "new_messages", "response_body"} {
assert.NotContains(t, meta, key)
}
assert.Equal(t, "gpt-4", meta["model_name"])
assert.Equal(t, float64(2), meta["turn_count"])
}

func TestAdminGetChatSessions_FiltersAndPaging(t *testing.T) {
setupChatLogTestDB(t)
for i, m := range []string{"gpt-4", "gpt-4", "claude-3"} {
s := &model.ChatSession{TokenId: i + 1, UserId: 1, ModelName: m, PrefixHash: fmt.Sprintf("h%d", i)}
require.NoError(t, s.Insert())
}

list := func(query string) (int64, []map[string]any) {
rec := serveChatLog(t, "/chat_logs/sessions", AdminGetChatSessions, "/chat_logs/sessions"+query)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Success bool `json:"success"`
Total int64 `json:"total"`
Data []map[string]any `json:"data"`
}
require.NoError(t, common.Unmarshal(rec.Body.Bytes(), &resp))
require.True(t, resp.Success)
return resp.Total, resp.Data
}

total, data := list("?page=1&page_size=2")
assert.Equal(t, int64(3), total)
assert.Len(t, data, 2)

total, data = list("?page=1&page_size=10&model_name=gpt-4")
assert.Equal(t, int64(2), total)
require.Len(t, data, 2)
assert.Equal(t, "gpt-4", data[0]["model_name"])
assert.Equal(t, "gpt-4", data[1]["model_name"])

total, data = list("?page=1&page_size=10&token_id=3")
assert.Equal(t, int64(1), total)
require.Len(t, data, 1)
assert.Equal(t, float64(3), data[0]["token_id"])

total, data = list("?page=1&page_size=10&model_name=claude-3&token_id=3")
assert.Equal(t, int64(1), total)
assert.Len(t, data, 1)

total, _ = list("?page=1&page_size=10&model_name=none")
assert.Equal(t, int64(0), total)
}

func TestAdminGetChatSessionDetail_TurnsOrdered(t *testing.T) {
setupChatLogTestDB(t)
s := &model.ChatSession{TokenId: 7, UserId: 1, ModelName: "gpt-4", PrefixHash: "h7"}
require.NoError(t, s.Insert())
turn2 := &model.ChatTurn{SessionId: s.Id, TurnIndex: 2, RequestId: "r2", ModelName: "gpt-4", NewMessages: `[{"role":"assistant"}]`, ResponseBody: `{"b":2}`}
require.NoError(t, turn2.Insert())
turn1 := &model.ChatTurn{SessionId: s.Id, TurnIndex: 1, RequestId: "r1", ModelName: "gpt-4", NewMessages: `[{"role":"user"}]`, ResponseBody: `{"a":1}`}
require.NoError(t, turn1.Insert())

rec := serveChatLog(t, "/chat_logs/sessions/:id", AdminGetChatSessionDetail, "/chat_logs/sessions/"+strconv.Itoa(s.Id))
require.Equal(t, http.StatusOK, rec.Code)
resp := struct {
Success bool `json:"success"`
Total int64 `json:"total"`
}{}

var resp struct {
Success bool `json:"success"`
Data struct {
Session *model.ChatSession `json:"session"`
Turns []*model.ChatTurn `json:"turns"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(rec.Body.Bytes(), &resp))
assert.True(t, resp.Success)
assert.Equal(t, int64(1), resp.Total)

// Detail
rec2 := httptest.NewRecorder()
r2 := gin.New()
r2.GET("/chat_logs/:id", authMW(common.RoleAdminUser), AdminGetChatLogDetail)
r2.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/chat_logs/"+strconv.Itoa(cl.Id), nil))
require.Equal(t, http.StatusOK, rec2.Code)
detail := struct {
Success bool `json:"success"`
Data *model.ChatLog `json:"data"`
}{}
require.NoError(t, common.Unmarshal(rec2.Body.Bytes(), &detail))
require.True(t, detail.Success)
assert.Equal(t, `{"q":1}`, detail.Data.RequestBody)
require.True(t, resp.Success)
require.NotNil(t, resp.Data.Session)
assert.Equal(t, s.Id, resp.Data.Session.Id)
require.Len(t, resp.Data.Turns, 2)
assert.Equal(t, 1, resp.Data.Turns[0].TurnIndex)
assert.Equal(t, "r1", resp.Data.Turns[0].RequestId)
assert.Equal(t, `{"a":1}`, resp.Data.Turns[0].ResponseBody)
assert.Equal(t, 2, resp.Data.Turns[1].TurnIndex)
assert.Equal(t, "r2", resp.Data.Turns[1].RequestId)

// nonexistent id -> 404
rec404 := serveChatLog(t, "/chat_logs/sessions/:id", AdminGetChatSessionDetail, "/chat_logs/sessions/99999")
assert.Equal(t, http.StatusNotFound, rec404.Code)
// invalid id -> 400
rec400 := serveChatLog(t, "/chat_logs/sessions/:id", AdminGetChatSessionDetail, "/chat_logs/sessions/abc")
assert.Equal(t, http.StatusBadRequest, rec400.Code)
}
2 changes: 1 addition & 1 deletion controller/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {

var chatLogRecorder *service.ChatLogRecorder
if tok, terr := model.GetTokenByKey(relayInfo.TokenKey, false); terr == nil && tok.ChatLogEnabled {
chatLogRecorder = service.MaybeInstallChatLogCapture(c)
chatLogRecorder = service.MaybeInstallChatLogCapture(c, relayFormat)
}

needSensitiveCheck := setting.ShouldCheckPromptSensitive()
Expand Down
40 changes: 40 additions & 0 deletions controller/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,46 @@ func GetTokenKeysBatch(c *gin.Context) {
common.ApiSuccess(c, gin.H{"keys": keysMap})
}

func AdminGetTokenKey(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, err)
return
}
token, err := model.GetTokenById(id)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgTokenGetInfoFailed)
return
}
recordManageAudit(c, "token.key_view", map[string]interface{}{
"id": id,
"name": token.Name,
})
common.ApiSuccess(c, gin.H{"key": token.GetFullKey()})
}

func AdminGetTokenKeysBatch(c *gin.Context) {
tokenBatch := TokenBatch{}
if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
if len(tokenBatch.Ids) > 100 {
common.ApiErrorI18n(c, i18n.MsgBatchTooMany, map[string]any{"Max": 100})
return
}
tokens, err := model.GetTokenKeysByIds(tokenBatch.Ids, 0)
if err != nil {
common.ApiError(c, err)
return
}
keysMap := make(map[int]string)
for _, t := range tokens {
keysMap[t.Id] = t.GetFullKey()
}
common.ApiSuccess(c, gin.H{"keys": keysMap})
}

func GetTokenChannelQuotas(c *gin.Context) {
tokenId, err := strconv.Atoi(c.Param("id"))
if err != nil {
Expand Down
29 changes: 29 additions & 0 deletions controller/token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -578,3 +578,32 @@ func TestGetTokenKeyRequiresOwnershipAndReturnsFullKey(t *testing.T) {
t.Fatalf("unauthorized key response leaked raw token key: %s", unauthorizedRecorder.Body.String())
}
}

func TestAdminGetTokenKeyReturnsOtherUsersFullKey(t *testing.T) {
db := setupTokenControllerTestDB(t)
token := seedToken(t, db, 1, "admin-viewed-token", "adminView312token456")

adminCtx, adminRecorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/admin/"+strconv.Itoa(token.Id)+"/key", nil, 99)
adminCtx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}}
AdminGetTokenKey(adminCtx)

adminResponse := decodeAPIResponse(t, adminRecorder)
if !adminResponse.Success {
t.Fatalf("expected admin key fetch to succeed, got message: %s", adminResponse.Message)
}
var keyData tokenKeyResponse
if err := common.Unmarshal(adminResponse.Data, &keyData); err != nil {
t.Fatalf("failed to decode token key response: %v", err)
}
if keyData.Key != token.GetFullKey() {
t.Fatalf("expected full key %q, got %q", token.GetFullKey(), keyData.Key)
}

missingCtx, missingRecorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/admin/9999/key", nil, 99)
missingCtx.Params = gin.Params{{Key: "id", Value: "9999"}}
AdminGetTokenKey(missingCtx)
missingResponse := decodeAPIResponse(t, missingRecorder)
if missingResponse.Success {
t.Fatalf("expected missing token fetch to fail")
}
}
Loading
Loading