Skip to content

Commit 99c52e6

Browse files
authored
feat: track OpenRouter usage (#134)
Store usage information in database to track user behavior (if user is not BYOK) as a precursor to billing users, e.g., Hourly: ``` { _id: ObjectId('69cd2e9a926e4b5665909027'), user_id: ObjectId('6975da46d6096ac1b07342c2'), hour_bucket: ISODate('2026-04-01T14:00:00.000Z'), project_id: '69746440592738ba2708cc34', success_cost: 0.014026150000000001, failed_cost: 0.024026150312000001, updated_at: ISODate('2026-04-01T14:41:37.465Z') } ``` Weekly: ``` { _id: ObjectId('69cd2e9a926e4b5665909028'), week_bucket: ISODate('2026-03-30T00:00:00.000Z'), project_id: '69746440592738ba2708cc34', user_id: ObjectId('6975da46d6096ac1b07342c2'), success_cost: 0.014026150000000001, failed_cost: 0.024026150312000001, updated_at: ISODate('2026-04-01T14:41:37.465Z') } ``` Lifetime: ``` { _id: ObjectId('69cd2e9a926e4b5665909029'), user_id: ObjectId('6975da46d6096ac1b07342c2'), project_id: '69746440592738ba2708cc34', success_cost: 0.014026150000000001, failed_cost: 0.024026150312000001, updated_at: ISODate('2026-04-01T14:41:37.465Z') } ```
1 parent dcae304 commit 99c52e6

13 files changed

Lines changed: 4784 additions & 961 deletions

File tree

internal/api/chat/create_conversation_message_stream_v2.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,33 @@ func (s *ChatServerV2) CreateConversationMessageStream(
321321
}
322322
}
323323

324-
openaiChatHistory, inappChatHistory, err := s.aiClientV2.ChatCompletionStreamV2(ctx, stream, conversation.ID.Hex(), modelSlug, conversation.OpenaiChatHistoryCompletion, llmProvider, customModel)
324+
// Usage is the same as ChatCompletion, just passing the stream parameter
325+
326+
if customModel == nil {
327+
// User did not specify API key for this model
328+
llmProvider = &models.LLMProviderConfig{
329+
APIKey: "",
330+
IsCustomModel: false,
331+
}
332+
} else {
333+
customModel.BaseUrl = strings.ToLower(customModel.BaseUrl)
334+
335+
if strings.Contains(customModel.BaseUrl, "paperdebugger.com") {
336+
customModel.BaseUrl = ""
337+
}
338+
if !strings.HasPrefix(customModel.BaseUrl, "https://") {
339+
customModel.BaseUrl = strings.Replace(customModel.BaseUrl, "http://", "", 1)
340+
customModel.BaseUrl = "https://" + customModel.BaseUrl
341+
}
342+
343+
llmProvider = &models.LLMProviderConfig{
344+
APIKey: customModel.APIKey,
345+
Endpoint: customModel.BaseUrl,
346+
IsCustomModel: true,
347+
}
348+
}
349+
350+
openaiChatHistory, inappChatHistory, _, err := s.aiClientV2.ChatCompletionStreamV2(ctx, stream, conversation.UserID, conversation.ProjectID, conversation.ID.Hex(), modelSlug, conversation.OpenaiChatHistoryCompletion, llmProvider, customModel)
325351
if err != nil {
326352
return s.sendStreamError(stream, err)
327353
}
@@ -347,7 +373,7 @@ func (s *ChatServerV2) CreateConversationMessageStream(
347373
for i, bsonMsg := range conversation.InappChatHistory {
348374
protoMessages[i] = mapper.BSONToChatMessageV2(bsonMsg)
349375
}
350-
title, err := s.aiClientV2.GetConversationTitleV2(ctx, protoMessages, llmProvider, modelSlug, customModel)
376+
title, err := s.aiClientV2.GetConversationTitleV2(ctx, conversation.UserID, conversation.ProjectID, protoMessages, llmProvider, modelSlug, customModel)
351377
if err != nil {
352378
s.logger.Error("Failed to get conversation title", "error", err, "conversationID", conversation.ID.Hex())
353379
return

internal/models/usage.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package models
2+
3+
import (
4+
"time"
5+
6+
"go.mongodb.org/mongo-driver/v2/bson"
7+
)
8+
9+
// HourlyUsage tracks cost per user, per project, per hour.
10+
// Each document represents one hour bucket of usage.
11+
type HourlyUsage struct {
12+
ID bson.ObjectID `bson:"_id"`
13+
UserID bson.ObjectID `bson:"user_id"`
14+
ProjectID string `bson:"project_id"`
15+
HourBucket bson.DateTime `bson:"hour_bucket"` // Timestamp truncated to the hour
16+
SuccessCost float64 `bson:"success_cost"` // Cost in USD for successful requests
17+
FailedCost float64 `bson:"failed_cost"` // Cost in USD for failed requests
18+
UpdatedAt bson.DateTime `bson:"updated_at"`
19+
}
20+
21+
func (u HourlyUsage) CollectionName() string {
22+
return "hourly_usages"
23+
}
24+
25+
// WeeklyUsage tracks cost per user, per project, per week.
26+
// Each document represents one week bucket of usage.
27+
type WeeklyUsage struct {
28+
ID bson.ObjectID `bson:"_id"`
29+
UserID bson.ObjectID `bson:"user_id"`
30+
ProjectID string `bson:"project_id"`
31+
WeekBucket bson.DateTime `bson:"week_bucket"` // Timestamp truncated to the week (Monday)
32+
SuccessCost float64 `bson:"success_cost"` // Cost in USD for successful requests
33+
FailedCost float64 `bson:"failed_cost"` // Cost in USD for failed requests
34+
UpdatedAt bson.DateTime `bson:"updated_at"`
35+
}
36+
37+
func (u WeeklyUsage) CollectionName() string {
38+
return "weekly_usages"
39+
}
40+
41+
// LifetimeUsage tracks total cost per user, per project, across all time.
42+
// Each document represents the cumulative usage for a user-project pair.
43+
type LifetimeUsage struct {
44+
ID bson.ObjectID `bson:"_id"`
45+
UserID bson.ObjectID `bson:"user_id"`
46+
ProjectID string `bson:"project_id"`
47+
SuccessCost float64 `bson:"success_cost"` // Total cost in USD for successful requests
48+
FailedCost float64 `bson:"failed_cost"` // Total cost in USD for failed requests
49+
UpdatedAt bson.DateTime `bson:"updated_at"`
50+
}
51+
52+
func (u LifetimeUsage) CollectionName() string {
53+
return "lifetime_usages"
54+
}
55+
56+
// TruncateToHour truncates a time to the start of its hour.
57+
func TruncateToHour(t time.Time) time.Time {
58+
return t.Truncate(time.Hour)
59+
}
60+
61+
// TruncateToWeek truncates a time to the start of its week (Monday 00:00:00 UTC).
62+
func TruncateToWeek(t time.Time) time.Time {
63+
t = t.UTC()
64+
weekday := int(t.Weekday())
65+
if weekday == 0 {
66+
weekday = 7 // Sunday becomes 7
67+
}
68+
// Subtract days to get to Monday
69+
monday := t.AddDate(0, 0, -(weekday - 1))
70+
return time.Date(monday.Year(), monday.Month(), monday.Day(), 0, 0, 0, 0, time.UTC)
71+
}

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
}
@@ -62,6 +63,7 @@ func NewAIClientV2(
6263

6364
reverseCommentService *services.ReverseCommentService,
6465
projectService *services.ProjectService,
66+
usageService *services.UsageService,
6567
cfg *cfg.Cfg,
6668
logger *logger.Logger,
6769
) *AIClientV2 {
@@ -109,6 +111,7 @@ func NewAIClientV2(
109111

110112
reverseCommentService: reverseCommentService,
111113
projectService: projectService,
114+
usageService: usageService,
112115
cfg: cfg,
113116
logger: logger,
114117
}

internal/services/toolkit/client/completion_v2.go

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@ import (
66
"paperdebugger/internal/models"
77
"paperdebugger/internal/services/toolkit/handler"
88
chatv2 "paperdebugger/pkg/gen/api/chat/v2"
9+
"strconv"
910
"strings"
11+
"time"
1012

1113
"github.com/openai/openai-go/v3"
14+
"go.mongodb.org/mongo-driver/v2/bson"
1215
)
1316

17+
// UsageCost holds cost information from a completion.
18+
type UsageCost struct {
19+
Cost float64
20+
}
21+
1422
// define []openai.ChatCompletionMessageParamUnion as OpenAIChatHistory
1523

1624
// ChatCompletion orchestrates a chat completion process with a language model (e.g., GPT), handling tool calls and message history management.
@@ -24,13 +32,14 @@ import (
2432
// Returns:
2533
// 1. The full chat history sent to the language model (including any tool call results).
2634
// 2. The incremental chat history visible to the user (including tool call results and assistant responses).
27-
// 3. An error, if any occurred during the process.
28-
func (a *AIClientV2) ChatCompletionV2(ctx context.Context, modelSlug string, messages OpenAIChatHistory, llmProvider *models.LLMProviderConfig, customModel *models.CustomModel) (OpenAIChatHistory, AppChatHistory, error) {
29-
openaiChatHistory, inappChatHistory, err := a.ChatCompletionStreamV2(ctx, nil, "", modelSlug, messages, llmProvider, customModel)
35+
// 3. Cost information (in USD).
36+
// 4. An error, if any occurred during the process.
37+
func (a *AIClientV2) ChatCompletionV2(ctx context.Context, userID bson.ObjectID, projectID string, modelSlug string, messages OpenAIChatHistory, llmProvider *models.LLMProviderConfig, customModel *models.CustomModel) (OpenAIChatHistory, AppChatHistory, UsageCost, error) {
38+
openaiChatHistory, inappChatHistory, usage, err := a.ChatCompletionStreamV2(ctx, nil, userID, projectID, "", modelSlug, messages, llmProvider, customModel)
3039
if err != nil {
31-
return nil, nil, err
40+
return nil, nil, usage, err
3241
}
33-
return openaiChatHistory, inappChatHistory, nil
42+
return openaiChatHistory, inappChatHistory, usage, nil
3443
}
3544

3645
// ChatCompletionStream orchestrates a streaming chat completion process with a language model (e.g., GPT), handling tool calls, message history management, and real-time streaming of responses to the client.
@@ -46,17 +55,20 @@ func (a *AIClientV2) ChatCompletionV2(ctx context.Context, modelSlug string, mes
4655
// Returns: (same as ChatCompletion)
4756
// 1. The full chat history sent to the language model (including any tool call results).
4857
// 2. The incremental chat history visible to the user (including tool call results and assistant responses).
49-
// 3. An error, if any occurred during the process. (However, in the streaming mode, the error is not returned, but sending by callbackStream)
58+
// 3. Cost information (in USD, accumulated across all calls).
59+
// 4. An error, if any occurred during the process. (However, in the streaming mode, the error is not returned, but sending by callbackStream)
5060
//
5161
// This function works as follows: (same as ChatCompletion)
5262
// - It initializes the chat history for the language model and the user, and sets up a stream handler for real-time updates.
5363
// - It repeatedly sends the current chat history to the language model, receives streaming responses, and forwards them to the client as they arrive.
5464
// - If tool calls are required, it handles them and appends the results to the chat history, then continues the loop.
5565
// - If no tool calls are needed, it appends the assistant's response and exits the loop.
56-
// - 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, customModel *models.CustomModel) (OpenAIChatHistory, AppChatHistory, error) {
66+
// - Finally, it returns the updated chat histories, accumulated cost, and any error encountered.
67+
func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream chatv2.ChatService_CreateConversationMessageStreamServer, userID bson.ObjectID, projectID string, conversationId string, modelSlug string, messages OpenAIChatHistory, llmProvider *models.LLMProviderConfig, customModel *models.CustomModel) (OpenAIChatHistory, AppChatHistory, UsageCost, error) {
5868
openaiChatHistory := messages
5969
inappChatHistory := AppChatHistory{}
70+
usage := UsageCost{}
71+
success := false // Track whether the request completed successfully
6072

6173
streamHandler := handler.NewStreamHandlerV2(callbackStream, conversationId, modelSlug)
6274

@@ -65,6 +77,19 @@ func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream
6577
streamHandler.SendFinalization()
6678
}()
6779

80+
// Track usage on all exit paths (success or error) to prevent abuse
81+
// Only track if userID is provided and user is not using their own API key (BYOK)
82+
defer func() {
83+
if !userID.IsZero() && !llmProvider.IsCustomModel && usage.Cost > 0 {
84+
// Use a detached context since the request context may be canceled
85+
trackCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
86+
defer cancel()
87+
if err := a.usageService.TrackUsage(trackCtx, userID, projectID, usage.Cost, success); err != nil {
88+
a.logger.Error("Error while tracking usage", "error", err)
89+
}
90+
}
91+
}()
92+
6893
oaiClient := a.GetOpenAIClient(llmProvider)
6994
params := getDefaultParamsV2(modelSlug, a.toolCallHandler.Registry, customModel)
7095

@@ -77,6 +102,7 @@ func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream
77102
answer_content := ""
78103
answer_content_id := ""
79104
has_sent_part_begin := false
105+
has_finished := false
80106
tool_info := map[int]map[string]string{}
81107
toolCalls := []openai.FinishedChatCompletionToolCall{}
82108
handleReasoning := func(raw string) (string, bool) {
@@ -92,12 +118,18 @@ func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream
92118
}
93119

94120
for stream.Next() {
95-
// time.Sleep(5000 * time.Millisecond) // DEBUG POINT: change this to test in a slow mode
96121
chunk := stream.Current()
97122

123+
// Capture cost from any chunk that has usage data (OpenRouter sends usage in a separate chunk after FinishReason)
124+
if chunk.Usage.PromptTokens > 0 || chunk.Usage.CompletionTokens > 0 {
125+
if costField, ok := chunk.Usage.JSON.ExtraFields["cost"]; ok {
126+
if cost, err := strconv.ParseFloat(costField.Raw(), 64); err == nil {
127+
usage.Cost += cost
128+
}
129+
}
130+
}
131+
98132
if len(chunk.Choices) == 0 {
99-
// Handle usage information
100-
// fmt.Printf("Usage: %+v\n", chunk.Usage)
101133
continue
102134
}
103135

@@ -180,17 +212,15 @@ func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream
180212
}
181213
}
182214

183-
if chunk.Choices[0].FinishReason != "" {
184-
// fmt.Printf("FinishReason: %s\n", chunk.Choices[0].FinishReason)
185-
// answer_content += chunk.Choices[0].Delta.Content
186-
// fmt.Printf("answer_content: %s\n", answer_content)
215+
if chunk.Choices[0].FinishReason != "" && !has_finished {
187216
streamHandler.HandleTextDoneItem(chunk, answer_content, reasoning_content)
188-
break
217+
has_finished = true
218+
// Don't break - continue reading to capture the usage chunk that comes after
189219
}
190220
}
191221

192222
if err := stream.Err(); err != nil {
193-
return nil, nil, err
223+
return nil, nil, usage, err
194224
}
195225

196226
if answer_content != "" {
@@ -200,7 +230,7 @@ func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream
200230
// Execute the calls (if any), return incremental data
201231
openaiToolHistory, inappToolHistory, err := a.toolCallHandler.HandleToolCallsV2(ctx, toolCalls, streamHandler)
202232
if err != nil {
203-
return nil, nil, err
233+
return nil, nil, usage, err
204234
}
205235

206236
// // Record the tool call results
@@ -213,5 +243,6 @@ func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream
213243
}
214244
}
215245

216-
return openaiChatHistory, inappChatHistory, nil
246+
success = true
247+
return openaiChatHistory, inappChatHistory, usage, nil
217248
}

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, projectId, "gpt-5.2", OpenAIChatHistory{
245245
openai.SystemMessage("You are a helpful assistant that suggests relevant citation keys."),
246246
openai.UserMessage(message),
247247
}, llmProvider, nil)

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, modelSlug string, customModel *models.CustomModel) (string, error) {
17+
func (a *AIClientV2) GetConversationTitleV2(ctx context.Context, userID bson.ObjectID, projectID string, inappChatHistory []*chatv2.Message, llmProvider *models.LLMProviderConfig, modelSlug string, customModel *models.CustomModel) (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())
@@ -35,7 +36,7 @@ func (a *AIClientV2) GetConversationTitleV2(ctx context.Context, inappChatHistor
3536
modelToUse = modelSlug
3637
}
3738

38-
_, resp, err := a.ChatCompletionV2(ctx, modelToUse, OpenAIChatHistory{
39+
_, resp, _, err := a.ChatCompletionV2(ctx, userID, projectID, modelToUse, OpenAIChatHistory{
3940
openai.SystemMessage("You are a helpful assistant that generates a title for a conversation."),
4041
openai.UserMessage(message),
4142
}, llmProvider, customModel)

internal/services/toolkit/client/utils_v2.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ func getDefaultParamsV2(modelSlug string, toolRegistry *registry.ToolRegistryV2,
9494
Tools: toolRegistry.GetTools(),
9595
ParallelToolCalls: openaiv3.Bool(true),
9696
Store: openaiv3.Bool(false),
97+
StreamOptions: openaiv3.ChatCompletionStreamOptionsParam{
98+
IncludeUsage: openaiv3.Bool(true),
99+
},
97100
}
98101
}
99102
}
@@ -105,6 +108,9 @@ func getDefaultParamsV2(modelSlug string, toolRegistry *registry.ToolRegistryV2,
105108
Tools: toolRegistry.GetTools(), // Tool registration is managed centrally by the registry
106109
ParallelToolCalls: openaiv3.Bool(true),
107110
Store: openaiv3.Bool(false), // Must set to false, because we are construct our own chat history.
111+
StreamOptions: openaiv3.ChatCompletionStreamOptionsParam{
112+
IncludeUsage: openaiv3.Bool(true),
113+
},
108114
}
109115
}
110116

0 commit comments

Comments
 (0)