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
16 changes: 15 additions & 1 deletion internal/model/multi.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,24 @@ func mergeResults(results []*ReviewResult, threshold int) *ReviewResult {
}
}

return &ReviewResult{
// Aggregate token usage across all models.
var totalUsage TokenUsage
for _, r := range results {
if r != nil && r.Usage != nil {
totalUsage.InputTokens += r.Usage.InputTokens
totalUsage.OutputTokens += r.Usage.OutputTokens
totalUsage.TotalTokens += r.Usage.TotalTokens
}
}

merged := &ReviewResult{
Summary: strings.Join(summaries, " "),
Findings: findings,
}
if totalUsage.TotalTokens > 0 {
merged.Usage = &totalUsage
}
return merged
}

// findingsMatch returns true if two findings refer to the same issue:
Expand Down
39 changes: 39 additions & 0 deletions internal/model/multi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,45 @@ func TestMergeResults_NilResults(t *testing.T) {
}
}

func TestMergeResults_UsageAggregation(t *testing.T) {
results := []*ReviewResult{
{
Summary: "A",
Findings: []Finding{{File: "a.go", Line: 1, Category: "bug", Title: "x", Body: "y"}},
Usage: &TokenUsage{InputTokens: 1000, OutputTokens: 200, TotalTokens: 1200},
},
{
Summary: "B",
Findings: []Finding{{File: "a.go", Line: 1, Category: "bug", Title: "x", Body: "y"}},
Usage: &TokenUsage{InputTokens: 1500, OutputTokens: 300, TotalTokens: 1800},
},
}
merged := mergeResults(results, 2)
if merged.Usage == nil {
t.Fatal("expected usage to be set")
}
if merged.Usage.InputTokens != 2500 {
t.Errorf("expected 2500 input tokens, got %d", merged.Usage.InputTokens)
}
if merged.Usage.OutputTokens != 500 {
t.Errorf("expected 500 output tokens, got %d", merged.Usage.OutputTokens)
}
if merged.Usage.TotalTokens != 3000 {
t.Errorf("expected 3000 total tokens, got %d", merged.Usage.TotalTokens)
}
}

func TestMergeResults_UsageNilWhenNoUsage(t *testing.T) {
results := []*ReviewResult{
{Summary: "A", Findings: []Finding{{File: "a.go", Line: 1, Category: "bug", Title: "x", Body: "y"}}},
{Summary: "B", Findings: []Finding{{File: "a.go", Line: 1, Category: "bug", Title: "x", Body: "y"}}},
}
merged := mergeResults(results, 2)
if merged.Usage != nil {
t.Error("expected usage to be nil when no results have usage")
}
}

func TestMergeResults_SummaryMerge(t *testing.T) {
merged := mergeResults([]*ReviewResult{{Summary: "A."}, {Summary: "B."}, {Summary: "A."}}, 1)
if merged.Summary != "A. B." {
Expand Down
21 changes: 19 additions & 2 deletions internal/model/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,18 @@ import (
"google.golang.org/genai"
)

// TokenUsage tracks input/output token counts for cost visibility.
type TokenUsage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
}

// ReviewResult is the structured output from the model.
type ReviewResult struct {
Summary string `json:"summary"`
Findings []Finding `json:"findings"`
Summary string `json:"summary"`
Findings []Finding `json:"findings"`
Usage *TokenUsage `json:"usage,omitempty"`
}

// Finding is a single review comment from the model.
Expand Down Expand Up @@ -101,6 +109,15 @@ func (p *Provider) Review(ctx context.Context, systemPrompt, userPrompt string)
return nil, fmt.Errorf("parsing model response: %w (raw: %s)", err, truncate(text, 500))
}

// Capture token usage from the response.
if result.UsageMetadata != nil {
review.Usage = &TokenUsage{
InputTokens: int64(result.UsageMetadata.PromptTokenCount),
OutputTokens: int64(result.UsageMetadata.CandidatesTokenCount),
TotalTokens: int64(result.UsageMetadata.TotalTokenCount),
}
}

return review, nil
}

Expand Down
9 changes: 9 additions & 0 deletions internal/reviewer/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ func TerminalOutput(result *model.ReviewResult) string {

if len(result.Findings) == 0 {
sb.WriteString("✅ No issues found. Code looks clean and ready to merge.\n")
if result.Usage != nil && result.Usage.TotalTokens > 0 {
fmt.Fprintf(&sb, "---\nTokens: %d in / %d out / %d total\n",
result.Usage.InputTokens, result.Usage.OutputTokens, result.Usage.TotalTokens)
}
return sb.String()
}

Expand Down Expand Up @@ -47,6 +51,11 @@ func TerminalOutput(result *model.ReviewResult) string {
}
}

if result.Usage != nil && result.Usage.TotalTokens > 0 {
fmt.Fprintf(&sb, "---\nTokens: %d in / %d out / %d total\n",
result.Usage.InputTokens, result.Usage.OutputTokens, result.Usage.TotalTokens)
}

return sb.String()
}

Expand Down
68 changes: 68 additions & 0 deletions internal/reviewer/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,71 @@ func TestFormatInlineComment_WithSuggestion(t *testing.T) {
t.Error("expected suggestion content")
}
}

func TestTokenUsageRendering(t *testing.T) {
finding := model.Finding{File: "a.go", Line: 1, Severity: "LOW", Category: "style", Title: "test", Body: "body"}

tests := []struct {
name string
findings []model.Finding
usage *model.TokenUsage
wantContains []string // substrings that must appear
wantAbsent []string // substrings that must not appear
}{
{
name: "with findings and usage",
findings: []model.Finding{finding},
usage: &model.TokenUsage{InputTokens: 1500, OutputTokens: 200, TotalTokens: 1700},
wantContains: []string{"1500", "200", "1700"},
},
{
name: "nil usage hidden",
findings: nil,
usage: nil,
wantAbsent: []string{"Tokens:"},
},
{
name: "no findings but usage shown",
findings: nil,
usage: &model.TokenUsage{InputTokens: 3000, OutputTokens: 100, TotalTokens: 3100},
wantContains: []string{"3000", "3100"},
},
{
name: "findings present but nil usage hidden",
findings: []model.Finding{finding},
usage: nil,
wantAbsent: []string{"Tokens:"},
},
}

renderers := []struct {
name string
render func(*model.ReviewResult) string
}{
{"PlainText", func(r *model.ReviewResult) string { return TerminalOutput(r) }},
{"Color", func(r *model.ReviewResult) string { return ColorTerminalOutput(r, true) }},
}

for _, tt := range tests {
for _, renderer := range renderers {
t.Run(renderer.name+"/"+tt.name, func(t *testing.T) {
result := &model.ReviewResult{
Summary: "Review.",
Findings: tt.findings,
Usage: tt.usage,
}
out := renderer.render(result)
for _, s := range tt.wantContains {
if !strings.Contains(out, s) {
t.Errorf("expected %q in output, got:\n%s", s, out)
}
}
for _, s := range tt.wantAbsent {
if strings.Contains(out, s) {
t.Errorf("unexpected %q in output, got:\n%s", s, out)
}
}
})
}
}
}
17 changes: 17 additions & 0 deletions internal/reviewer/reviewer.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ func (r *Reviewer) Run(ctx context.Context) (int, error) {
systemPrompt := model.BuildPromptWithCustom(r.cfg.CustomPrompt, r.cfg.Focus, r.cfg.ExtraRules)
var allFindings []model.Finding
var summary string
var totalUsage model.TokenUsage

for i, chunk := range chunks {
slog.Info(fmt.Sprintf("reviewing chunk %d/%d (%d files, ~%d tokens)",
Expand All @@ -109,6 +110,19 @@ func (r *Reviewer) Run(ctx context.Context) (int, error) {
summary = result.Summary
}
allFindings = append(allFindings, result.Findings...)
if result.Usage != nil {
totalUsage.InputTokens += result.Usage.InputTokens
totalUsage.OutputTokens += result.Usage.OutputTokens
totalUsage.TotalTokens += result.Usage.TotalTokens
}
}

if totalUsage.TotalTokens > 0 {
slog.Info("token usage",
"input", totalUsage.InputTokens,
"output", totalUsage.OutputTokens,
"total", totalUsage.TotalTokens,
)
}

// Step 5: Validate line references.
Expand All @@ -122,6 +136,9 @@ func (r *Reviewer) Run(ctx context.Context) (int, error) {
Summary: summary,
Findings: allFindings,
}
if totalUsage.TotalTokens > 0 {
result.Usage = &totalUsage
}

// Step 7: Output.
if r.cfg.DryRun || !r.cfg.CIMode {
Expand Down
12 changes: 12 additions & 0 deletions internal/reviewer/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ func ColorTerminalOutput(result *model.ReviewResult, useColor bool) string {
sb.WriteString(ansiBold + ansiCyan + "│" + ansiReset + ansiDim + countsPadded + ansiBold + ansiCyan + "│" + ansiReset + "\n")
}

// Token usage line (if available).
if result.Usage != nil && result.Usage.TotalTokens > 0 {
usageLine := fmt.Sprintf(" Tokens: %d in / %d out / %d total",
result.Usage.InputTokens, result.Usage.OutputTokens, result.Usage.TotalTokens)
usagePadLen := 53 - len(usageLine)
if usagePadLen < 0 {
usagePadLen = 0
}
usagePadded := usageLine + strings.Repeat(" ", usagePadLen)
sb.WriteString(ansiBold + ansiCyan + "│" + ansiReset + ansiDim + usagePadded + ansiBold + ansiCyan + "│" + ansiReset + "\n")
}

sb.WriteString(ansiBold + ansiCyan + "└─────────────────────────────────────────────────────┘" + ansiReset + "\n")

// Summary text.
Expand Down
Loading