Pulse/internal/api/chat_service_adapter.go
rcourtman d852964696 fix(ai): record patrol and QuickAnalysis token usage in cost store for budget enforcement
Patrol runs, evaluation passes, and QuickAnalysis calls were consuming
LLM tokens without recording them in the cost store. This made the
cost_budget_usd_30d budget setting ineffective since enforceBudget()
never saw patrol spend.

- Add RecordUsage() to ai.Service for thread-safe cost recording
- Add recordPatrolUsage() helper to PatrolService, called on both
  success and error paths for main patrol and evaluation pass
- Record QuickAnalysis token usage in cost store
- Return partial PatrolResponse (with token counts) on error instead
  of nil, so callers can always record consumed tokens
- Propagate partial response through chat_service_adapter on error
2026-03-01 19:19:47 +00:00

120 lines
3.7 KiB
Go

package api
import (
"context"
"encoding/json"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/chat"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
// chatServiceAdapter wraps chat.Service to implement ai.ChatServiceProvider.
// This bridges the chat package (concrete) to the ai package (interface) without
// creating an import cycle.
type chatServiceAdapter struct {
svc *chat.Service
}
func (a *chatServiceAdapter) CreateSession(ctx context.Context) (*ai.ChatSession, error) {
session, err := a.svc.CreateSession(ctx)
if err != nil {
return nil, err
}
return &ai.ChatSession{ID: session.ID}, nil
}
func (a *chatServiceAdapter) ExecuteStream(ctx context.Context, req ai.ChatExecuteRequest, callback ai.ChatStreamCallback) error {
return a.svc.ExecuteStream(ctx, chat.ExecuteRequest{
Prompt: req.Prompt,
SessionID: req.SessionID,
}, adaptCallback(callback))
}
func (a *chatServiceAdapter) ExecutePatrolStream(ctx context.Context, req ai.PatrolExecuteRequest, callback ai.ChatStreamCallback) (*ai.PatrolStreamResponse, error) {
resp, err := a.svc.ExecutePatrolStream(ctx, chat.PatrolRequest{
Prompt: req.Prompt,
SystemPrompt: req.SystemPrompt,
SessionID: req.SessionID,
UseCase: req.UseCase,
MaxTurns: req.MaxTurns,
MaxTotalTokens: req.MaxTotalTokens,
}, adaptCallback(callback))
if err != nil {
// Propagate partial response (with token counts) alongside the error
// so callers can still record usage for budget tracking on failure.
if resp != nil {
return &ai.PatrolStreamResponse{
InputTokens: resp.InputTokens,
OutputTokens: resp.OutputTokens,
}, err
}
return nil, err
}
return &ai.PatrolStreamResponse{
Content: resp.Content,
InputTokens: resp.InputTokens,
OutputTokens: resp.OutputTokens,
StopReason: resp.StopReason,
}, nil
}
func (a *chatServiceAdapter) GetMessages(ctx context.Context, sessionID string) ([]ai.ChatMessage, error) {
messages, err := a.svc.GetMessages(ctx, sessionID)
if err != nil {
return nil, err
}
result := make([]ai.ChatMessage, len(messages))
for i, m := range messages {
msg := ai.ChatMessage{
ID: m.ID,
Role: m.Role,
Content: m.Content,
ReasoningContent: m.ReasoningContent,
Timestamp: m.Timestamp,
}
for _, tc := range m.ToolCalls {
msg.ToolCalls = append(msg.ToolCalls, ai.ChatToolCall{
ID: tc.ID,
Name: tc.Name,
Input: tc.Input,
})
}
if m.ToolResult != nil {
msg.ToolResult = &ai.ChatToolResult{
ToolUseID: m.ToolResult.ToolUseID,
Content: m.ToolResult.Content,
IsError: m.ToolResult.IsError,
}
}
result[i] = msg
}
return result, nil
}
func (a *chatServiceAdapter) DeleteSession(ctx context.Context, sessionID string) error {
return a.svc.DeleteSession(ctx, sessionID)
}
func (a *chatServiceAdapter) ReloadConfig(ctx context.Context, cfg *config.AIConfig) error {
return a.svc.Restart(ctx, cfg)
}
// GetExecutor exposes the underlying chat service's tool executor so that
// patrol can set the finding creator. This satisfies the
// chatServiceExecutorAccessor interface in the ai package.
func (a *chatServiceAdapter) GetExecutor() *tools.PulseToolExecutor {
return a.svc.GetExecutor()
}
// adaptCallback converts an ai.ChatStreamCallback to a chat.StreamCallback.
// The ai package uses []byte for event data, while the chat package uses json.RawMessage.
func adaptCallback(callback ai.ChatStreamCallback) chat.StreamCallback {
return func(event chat.StreamEvent) {
callback(ai.ChatStreamEvent{
Type: event.Type,
Data: json.RawMessage(event.Data),
})
}
}