mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-14 13:49:18 +02:00
Previously only Claude and Codex had log-scanning-level token usage reporting (Flow B). This adds scanners for the remaining three runtimes: - OpenCode: reads JSON message files from ~/.local/share/opencode/storage/message/ - OpenClaw: reads JSONL session files from ~/.openclaw/agents/*/sessions/ - Hermes: reads JSONL session files from ~/.hermes/sessions/ All three are registered in Scanner.Scan() and follow the same (date, provider, model) aggregation pattern as existing scanners.
100 lines
3.0 KiB
Go
100 lines
3.0 KiB
Go
package usage
|
|
|
|
import (
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseHermesFile(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
|
|
// Hermes session JSONL with usage_update entries (cumulative snapshots)
|
|
content := `{"type":"session_start","timestamp":"2026-04-10T14:00:00.000Z","model":"claude-sonnet-4-5"}
|
|
{"type":"usage_update","timestamp":"2026-04-10T14:01:00.000Z","model":"claude-sonnet-4-5","usage":{"inputTokens":1000,"outputTokens":200,"cachedReadTokens":500,"thoughtTokens":50}}
|
|
{"type":"usage_update","timestamp":"2026-04-10T14:02:00.000Z","model":"claude-sonnet-4-5","usage":{"inputTokens":3000,"outputTokens":600,"cachedReadTokens":1500,"thoughtTokens":100}}
|
|
`
|
|
|
|
filePath := filepath.Join(tmp, "session-001.jsonl")
|
|
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s := NewScanner(slog.Default())
|
|
record := s.parseHermesFile(filePath)
|
|
|
|
if record == nil {
|
|
t.Fatal("expected non-nil record")
|
|
}
|
|
|
|
if record.Provider != "hermes" {
|
|
t.Errorf("provider = %q, want %q", record.Provider, "hermes")
|
|
}
|
|
if record.Model != "claude-sonnet-4-5" {
|
|
t.Errorf("model = %q, want %q", record.Model, "claude-sonnet-4-5")
|
|
}
|
|
if record.Date != "2026-04-10" {
|
|
t.Errorf("date = %q, want %q", record.Date, "2026-04-10")
|
|
}
|
|
// Should take the last (cumulative) snapshot
|
|
if record.InputTokens != 3000 {
|
|
t.Errorf("input_tokens = %d, want %d", record.InputTokens, 3000)
|
|
}
|
|
// output_tokens + thought_tokens
|
|
if record.OutputTokens != 700 {
|
|
t.Errorf("output_tokens = %d, want %d (600 + 100)", record.OutputTokens, 700)
|
|
}
|
|
if record.CacheReadTokens != 1500 {
|
|
t.Errorf("cache_read_tokens = %d, want %d", record.CacheReadTokens, 1500)
|
|
}
|
|
}
|
|
|
|
func TestParseHermesFile_NoUsage(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
|
|
content := `{"type":"session_start","timestamp":"2026-04-10T14:00:00.000Z","model":"test-model"}
|
|
{"type":"message","timestamp":"2026-04-10T14:01:00.000Z","content":"hello"}
|
|
`
|
|
|
|
filePath := filepath.Join(tmp, "session-empty.jsonl")
|
|
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s := NewScanner(slog.Default())
|
|
record := s.parseHermesFile(filePath)
|
|
|
|
if record != nil {
|
|
t.Errorf("expected nil record for no usage data, got %+v", record)
|
|
}
|
|
}
|
|
|
|
func TestParseHermesFile_SingleUsage(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
|
|
content := `{"type":"usage_update","timestamp":"2026-04-10T14:01:00.000Z","model":"hermes-3","usage":{"inputTokens":500,"outputTokens":100,"cachedReadTokens":0,"thoughtTokens":0}}
|
|
`
|
|
|
|
filePath := filepath.Join(tmp, "session-single.jsonl")
|
|
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s := NewScanner(slog.Default())
|
|
record := s.parseHermesFile(filePath)
|
|
|
|
if record == nil {
|
|
t.Fatal("expected non-nil record")
|
|
}
|
|
if record.InputTokens != 500 {
|
|
t.Errorf("input_tokens = %d, want %d", record.InputTokens, 500)
|
|
}
|
|
if record.OutputTokens != 100 {
|
|
t.Errorf("output_tokens = %d, want %d", record.OutputTokens, 100)
|
|
}
|
|
if record.Model != "hermes-3" {
|
|
t.Errorf("model = %q, want %q", record.Model, "hermes-3")
|
|
}
|
|
}
|