Skip to content

Commit b9bda9c

Browse files
committed
feat: LLM usage tracking, backend
1 parent 2e593f7 commit b9bda9c

13 files changed

Lines changed: 298 additions & 15 deletions

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
5555
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
5656
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
5757
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
58+
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
59+
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
5860
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
5961
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
6062
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
@@ -160,6 +162,8 @@ golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sU
160162
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
161163
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
162164
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
165+
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
166+
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
163167
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
164168
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
165169
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -188,6 +192,8 @@ golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
188192
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
189193
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
190194
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
195+
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
196+
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
191197
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
192198
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
193199
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=

internal/api/chat/create_conversation_message_stream_v2.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ func (s *ChatServerV2) CreateConversationMessageStream(
281281
APIKey: settings.OpenAIAPIKey,
282282
}
283283

284-
openaiChatHistory, inappChatHistory, err := s.aiClientV2.ChatCompletionStreamV2(ctx, stream, conversation.ID.Hex(), modelSlug, conversation.OpenaiChatHistoryCompletion, llmProvider)
284+
openaiChatHistory, inappChatHistory, err := s.aiClientV2.ChatCompletionStreamV2(ctx, stream, conversation.UserID, conversation.ID.Hex(), modelSlug, conversation.OpenaiChatHistoryCompletion, llmProvider)
285285
if err != nil {
286286
return s.sendStreamError(stream, err)
287287
}
@@ -307,7 +307,7 @@ func (s *ChatServerV2) CreateConversationMessageStream(
307307
for i, bsonMsg := range conversation.InappChatHistory {
308308
protoMessages[i] = mapper.BSONToChatMessageV2(bsonMsg)
309309
}
310-
title, err := s.aiClientV2.GetConversationTitleV2(ctx, protoMessages, llmProvider)
310+
title, err := s.aiClientV2.GetConversationTitleV2(ctx, conversation.UserID, protoMessages, llmProvider)
311311
if err != nil {
312312
s.logger.Error("Failed to get conversation title", "error", err, "conversationID", conversation.ID.Hex())
313313
return

internal/libs/db/db.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"paperdebugger/internal/libs/cfg"
88
"paperdebugger/internal/libs/logger"
9+
"paperdebugger/internal/models"
910

1011
"go.mongodb.org/mongo-driver/v2/bson"
1112
"go.mongodb.org/mongo-driver/v2/mongo"
@@ -43,5 +44,33 @@ func NewDB(cfg *cfg.Cfg, logger *logger.Logger) (*DB, error) {
4344
}
4445

4546
logger.Info("[MONGO] initialized")
46-
return &DB{Client: client, cfg: cfg, logger: logger}, nil
47+
48+
db := &DB{Client: client, cfg: cfg, logger: logger}
49+
db.ensureIndexes()
50+
return db, nil
51+
}
52+
53+
// ensureIndexes creates necessary indexes for the database collections.
54+
func (db *DB) ensureIndexes() {
55+
sessions := db.Database("paperdebugger").Collection((models.LLMSession{}).CollectionName())
56+
57+
// TTL index: auto-delete sessions after 30 days
58+
_, err := sessions.Indexes().CreateOne(context.Background(), mongo.IndexModel{
59+
Keys: bson.D{{Key: "session_expiry", Value: 1}},
60+
Options: options.Index().SetExpireAfterSeconds(30 * 24 * 60 * 60),
61+
})
62+
if err != nil {
63+
db.logger.Error("Failed to create TTL index on llm_sessions", "error", err)
64+
}
65+
66+
// Compound index for efficient active session lookups
67+
_, err = sessions.Indexes().CreateOne(context.Background(), mongo.IndexModel{
68+
Keys: bson.D{
69+
{Key: "user_id", Value: 1},
70+
{Key: "session_expiry", Value: -1},
71+
},
72+
})
73+
if err != nil {
74+
db.logger.Error("Failed to create compound index on llm_sessions", "error", err)
75+
}
4776
}

internal/models/usage.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package models
2+
3+
import "go.mongodb.org/mongo-driver/v2/bson"
4+
5+
// LLMSession represents a user's session for tracking LLM usage and token counts.
6+
type LLMSession struct {
7+
ID bson.ObjectID `bson:"_id"`
8+
UserID bson.ObjectID `bson:"user_id"`
9+
SessionStart bson.DateTime `bson:"session_start"`
10+
SessionExpiry bson.DateTime `bson:"session_expiry"`
11+
PromptTokens int64 `bson:"prompt_tokens"`
12+
CompletionTokens int64 `bson:"completion_tokens"`
13+
TotalTokens int64 `bson:"total_tokens"`
14+
RequestCount int64 `bson:"request_count"`
15+
}
16+
17+
func (s LLMSession) CollectionName() string {
18+
return "llm_sessions"
19+
}

internal/services/toolkit/client/client_v2.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type AIClientV2 struct {
2020

2121
reverseCommentService *services.ReverseCommentService
2222
projectService *services.ProjectService
23+
usageService *services.UsageService
2324
cfg *cfg.Cfg
2425
logger *logger.Logger
2526
}
@@ -60,6 +61,7 @@ func NewAIClientV2(
6061

6162
reverseCommentService *services.ReverseCommentService,
6263
projectService *services.ProjectService,
64+
usageService *services.UsageService,
6365
cfg *cfg.Cfg,
6466
logger *logger.Logger,
6567
) *AIClientV2 {
@@ -107,6 +109,7 @@ func NewAIClientV2(
107109

108110
reverseCommentService: reverseCommentService,
109111
projectService: projectService,
112+
usageService: usageService,
110113
cfg: cfg,
111114
logger: logger,
112115
}

internal/services/toolkit/client/completion_v2.go

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import (
44
"context"
55
"encoding/json"
66
"paperdebugger/internal/models"
7+
"paperdebugger/internal/services"
78
"paperdebugger/internal/services/toolkit/handler"
89
chatv2 "paperdebugger/pkg/gen/api/chat/v2"
910
"strings"
1011

1112
"github.com/openai/openai-go/v3"
13+
"go.mongodb.org/mongo-driver/v2/bson"
1214
)
1315

1416
// define []openai.ChatCompletionMessageParamUnion as OpenAIChatHistory
@@ -25,8 +27,8 @@ import (
2527
// 1. The full chat history sent to the language model (including any tool call results).
2628
// 2. The incremental chat history visible to the user (including tool call results and assistant responses).
2729
// 3. An error, if any occurred during the process.
28-
func (a *AIClientV2) ChatCompletionV2(ctx context.Context, modelSlug string, messages OpenAIChatHistory, llmProvider *models.LLMProviderConfig) (OpenAIChatHistory, AppChatHistory, error) {
29-
openaiChatHistory, inappChatHistory, err := a.ChatCompletionStreamV2(ctx, nil, "", modelSlug, messages, llmProvider)
30+
func (a *AIClientV2) ChatCompletionV2(ctx context.Context, userID bson.ObjectID, modelSlug string, messages OpenAIChatHistory, llmProvider *models.LLMProviderConfig) (OpenAIChatHistory, AppChatHistory, error) {
31+
openaiChatHistory, inappChatHistory, err := a.ChatCompletionStreamV2(ctx, nil, userID, "", modelSlug, messages, llmProvider)
3032
if err != nil {
3133
return nil, nil, err
3234
}
@@ -54,7 +56,7 @@ func (a *AIClientV2) ChatCompletionV2(ctx context.Context, modelSlug string, mes
5456
// - If tool calls are required, it handles them and appends the results to the chat history, then continues the loop.
5557
// - If no tool calls are needed, it appends the assistant's response and exits the loop.
5658
// - Finally, it returns the updated chat histories and any error encountered.
57-
func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream chatv2.ChatService_CreateConversationMessageStreamServer, conversationId string, modelSlug string, messages OpenAIChatHistory, llmProvider *models.LLMProviderConfig) (OpenAIChatHistory, AppChatHistory, error) {
59+
func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream chatv2.ChatService_CreateConversationMessageStreamServer, userID bson.ObjectID, conversationId string, modelSlug string, messages OpenAIChatHistory, llmProvider *models.LLMProviderConfig) (OpenAIChatHistory, AppChatHistory, error) {
5860
openaiChatHistory := messages
5961
inappChatHistory := AppChatHistory{}
6062

@@ -96,8 +98,46 @@ func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream
9698
chunk := stream.Current()
9799

98100
if len(chunk.Choices) == 0 {
99-
// Handle usage information
100-
// fmt.Printf("Usage: %+v\n", chunk.Usage)
101+
if chunk.Usage.TotalTokens > 0 {
102+
// Record usage and log stats asynchronously to avoid blocking the response
103+
go func(usage services.UsageRecord) {
104+
bgCtx := context.Background()
105+
if err := a.usageService.RecordUsage(bgCtx, usage); err != nil {
106+
a.logger.Error("Failed to store usage", "error", err)
107+
return
108+
}
109+
110+
// Log session usage
111+
session, err := a.usageService.GetActiveSession(bgCtx, usage.UserID)
112+
if err != nil {
113+
a.logger.Error("Failed to get active session", "error", err)
114+
} else if session != nil {
115+
a.logger.Info("Session usage",
116+
"user_id", usage.UserID.Hex(),
117+
"session_tokens", session.TotalTokens,
118+
"session_requests", session.RequestCount,
119+
)
120+
}
121+
122+
// Log weekly usage
123+
weeklyStats, err := a.usageService.GetWeeklyUsage(bgCtx, usage.UserID)
124+
if err != nil {
125+
a.logger.Error("Failed to get weekly usage", "error", err)
126+
} else if weeklyStats != nil {
127+
a.logger.Info("Weekly usage",
128+
"user_id", usage.UserID.Hex(),
129+
"weekly_tokens", weeklyStats.TotalTokens,
130+
"weekly_requests", weeklyStats.RequestCount,
131+
"weekly_sessions", weeklyStats.SessionCount,
132+
)
133+
}
134+
}(services.UsageRecord{
135+
UserID: userID,
136+
PromptTokens: chunk.Usage.PromptTokens,
137+
CompletionTokens: chunk.Usage.CompletionTokens,
138+
TotalTokens: chunk.Usage.TotalTokens,
139+
})
140+
}
101141
continue
102142
}
103143

@@ -185,7 +225,6 @@ func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream
185225
// answer_content += chunk.Choices[0].Delta.Content
186226
// fmt.Printf("answer_content: %s\n", answer_content)
187227
streamHandler.HandleTextDoneItem(chunk, answer_content, reasoning_content)
188-
break
189228
}
190229
}
191230

internal/services/toolkit/client/get_citation_keys.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ func (a *AIClientV2) GetCitationKeys(ctx context.Context, sentence string, userI
241241
// Bibliography is placed at the start of the prompt to leverage prompt caching
242242
message := fmt.Sprintf("Bibliography: %s\nSentence: %s\nBased on the sentence and bibliography, suggest only the most relevant citation keys separated by commas with no spaces (e.g. key1,key2). Be selective and only include citations that are directly relevant. Avoid suggesting more than 3 citations. If no relevant citations are found, return '%s'.", bibliography, sentence, emptyCitation)
243243

244-
_, resp, err := a.ChatCompletionV2(ctx, "gpt-5.2", OpenAIChatHistory{
244+
_, resp, err := a.ChatCompletionV2(ctx, userId, "gpt-5.2", OpenAIChatHistory{
245245
openai.SystemMessage("You are a helpful assistant that suggests relevant citation keys."),
246246
openai.UserMessage(message),
247247
}, llmProvider)

internal/services/toolkit/client/get_citation_keys_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ func setupTestClient(t *testing.T) (*client.AIClientV2, *services.ProjectService
2525
}
2626

2727
projectService := services.NewProjectService(dbInstance, cfg.GetCfg(), logger.GetLogger())
28+
usageService := services.NewUsageService(dbInstance, cfg.GetCfg(), logger.GetLogger())
2829
aiClient := client.NewAIClientV2(
2930
dbInstance,
3031
&services.ReverseCommentService{},
3132
projectService,
33+
usageService,
3234
cfg.GetCfg(),
3335
logger.GetLogger(),
3436
)

internal/services/toolkit/client/get_conversation_title_v2.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ import (
1111

1212
"github.com/openai/openai-go/v3"
1313
"github.com/samber/lo"
14+
"go.mongodb.org/mongo-driver/v2/bson"
1415
)
1516

16-
func (a *AIClientV2) GetConversationTitleV2(ctx context.Context, inappChatHistory []*chatv2.Message, llmProvider *models.LLMProviderConfig) (string, error) {
17+
func (a *AIClientV2) GetConversationTitleV2(ctx context.Context, userID bson.ObjectID, inappChatHistory []*chatv2.Message, llmProvider *models.LLMProviderConfig) (string, error) {
1718
messages := lo.Map(inappChatHistory, func(message *chatv2.Message, _ int) string {
1819
if _, ok := message.Payload.MessageType.(*chatv2.MessagePayload_Assistant); ok {
1920
return fmt.Sprintf("Assistant: %s", message.Payload.GetAssistant().GetContent())
@@ -29,7 +30,7 @@ func (a *AIClientV2) GetConversationTitleV2(ctx context.Context, inappChatHistor
2930
message := strings.Join(messages, "\n")
3031
message = fmt.Sprintf("%s\nBased on above conversation, generate a short, clear, and descriptive title that summarizes the main topic or purpose of the discussion. The title should be concise, specific, and use natural language. Avoid vague or generic titles. Use abbreviation and short words if possible. Use 3-5 words if possible. Give me the title only, no other text including any other words.", message)
3132

32-
_, resp, err := a.ChatCompletionV2(ctx, "gpt-5-nano", OpenAIChatHistory{
33+
_, resp, err := a.ChatCompletionV2(ctx, userID, "gpt-5-nano", OpenAIChatHistory{
3334
openai.SystemMessage("You are a helpful assistant that generates a title for a conversation."),
3435
openai.UserMessage(message),
3536
}, llmProvider)

internal/services/toolkit/client/utils_v2.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ func getDefaultParamsV2(modelSlug string, toolRegistry *registry.ToolRegistryV2)
7474
Tools: toolRegistry.GetTools(),
7575
ParallelToolCalls: openaiv3.Bool(true),
7676
Store: openaiv3.Bool(false),
77+
StreamOptions: openaiv3.ChatCompletionStreamOptionsParam{
78+
IncludeUsage: openaiv3.Bool(true),
79+
},
7780
}
7881
}
7982
}
@@ -85,6 +88,9 @@ func getDefaultParamsV2(modelSlug string, toolRegistry *registry.ToolRegistryV2)
8588
Tools: toolRegistry.GetTools(), // Tool registration is managed centrally by the registry
8689
ParallelToolCalls: openaiv3.Bool(true),
8790
Store: openaiv3.Bool(false), // Must set to false, because we are construct our own chat history.
91+
StreamOptions: openaiv3.ChatCompletionStreamOptionsParam{
92+
IncludeUsage: openaiv3.Bool(true),
93+
},
8894
}
8995
}
9096

0 commit comments

Comments
 (0)