mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Merge pull request #824 from multica-ai/agent/j/642cc7b4
feat(daemon): add token usage log scanning for OpenCode, OpenClaw, Hermes
This commit is contained in:
173
server/internal/daemon/usage/hermes.go
Normal file
173
server/internal/daemon/usage/hermes.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package usage
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// scanHermes reads Hermes JSONL session files from
|
||||
// ~/.hermes/sessions/*.jsonl
|
||||
// and extracts token usage from assistant message and usage_update entries.
|
||||
//
|
||||
// Hermes communicates via the ACP (Agent Communication Protocol) and logs
|
||||
// session events as JSONL. Token usage appears in:
|
||||
// - "assistant" messages with a "usage" field
|
||||
// - "usage_update" notification entries with cumulative token snapshots
|
||||
func (s *Scanner) scanHermes() []Record {
|
||||
root := hermesSessionRoot()
|
||||
if root == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Glob for session files: sessions/*.jsonl
|
||||
pattern := filepath.Join(root, "*.jsonl")
|
||||
files, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
s.logger.Debug("hermes glob error", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var allRecords []Record
|
||||
for _, f := range files {
|
||||
record := s.parseHermesFile(f)
|
||||
if record != nil {
|
||||
allRecords = append(allRecords, *record)
|
||||
}
|
||||
}
|
||||
|
||||
return mergeRecords(allRecords)
|
||||
}
|
||||
|
||||
// hermesSessionRoot returns the Hermes sessions directory.
|
||||
func hermesSessionRoot() string {
|
||||
if hermesHome := os.Getenv("HERMES_HOME"); hermesHome != "" {
|
||||
dir := filepath.Join(hermesHome, "sessions")
|
||||
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check common locations.
|
||||
candidates := []string{
|
||||
filepath.Join(home, ".hermes", "sessions"),
|
||||
filepath.Join(home, ".local", "share", "hermes", "sessions"),
|
||||
filepath.Join(home, ".config", "hermes", "sessions"),
|
||||
}
|
||||
for _, dir := range candidates {
|
||||
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// hermesLine represents a line in a Hermes session JSONL file.
|
||||
// Hermes session logs contain both message events and notification events.
|
||||
type hermesLine struct {
|
||||
Type string `json:"type"`
|
||||
Timestamp string `json:"timestamp"` // RFC3339
|
||||
Model string `json:"model"`
|
||||
Usage *struct {
|
||||
InputTokens int64 `json:"inputTokens"`
|
||||
OutputTokens int64 `json:"outputTokens"`
|
||||
CachedReadTokens int64 `json:"cachedReadTokens"`
|
||||
ThoughtTokens int64 `json:"thoughtTokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
// parseHermesFile extracts the final cumulative token usage from a Hermes session file.
|
||||
// Hermes usage_update events are cumulative snapshots — the last one in the file
|
||||
// represents the total usage for the session. Returns nil if no usage data found.
|
||||
func (s *Scanner) parseHermesFile(path string) *Record {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var lastUsage *struct {
|
||||
InputTokens int64 `json:"inputTokens"`
|
||||
OutputTokens int64 `json:"outputTokens"`
|
||||
CachedReadTokens int64 `json:"cachedReadTokens"`
|
||||
ThoughtTokens int64 `json:"thoughtTokens"`
|
||||
}
|
||||
var lastModel string
|
||||
var lastTimestamp string
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
// Fast pre-filter.
|
||||
if !bytesContains(line, `"usage"`) && !bytesContains(line, `"inputTokens"`) {
|
||||
continue
|
||||
}
|
||||
|
||||
var entry hermesLine
|
||||
if err := json.Unmarshal(line, &entry); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if entry.Usage == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Take the latest usage snapshot (cumulative).
|
||||
lastUsage = entry.Usage
|
||||
if entry.Model != "" {
|
||||
lastModel = entry.Model
|
||||
}
|
||||
if entry.Timestamp != "" {
|
||||
lastTimestamp = entry.Timestamp
|
||||
}
|
||||
}
|
||||
|
||||
if lastUsage == nil {
|
||||
return nil
|
||||
}
|
||||
if lastUsage.InputTokens == 0 && lastUsage.OutputTokens == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse timestamp for date.
|
||||
var date string
|
||||
if lastTimestamp != "" {
|
||||
if ts, err := time.Parse(time.RFC3339Nano, lastTimestamp); err == nil {
|
||||
date = ts.Local().Format("2006-01-02")
|
||||
} else if ts, err := time.Parse(time.RFC3339, lastTimestamp); err == nil {
|
||||
date = ts.Local().Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
if date == "" {
|
||||
// Fall back to file modification time.
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
date = info.ModTime().Local().Format("2006-01-02")
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
model := lastModel
|
||||
if model == "" {
|
||||
model = "unknown"
|
||||
}
|
||||
|
||||
return &Record{
|
||||
Date: date,
|
||||
Provider: "hermes",
|
||||
Model: model,
|
||||
InputTokens: lastUsage.InputTokens,
|
||||
OutputTokens: lastUsage.OutputTokens + lastUsage.ThoughtTokens,
|
||||
CacheReadTokens: lastUsage.CachedReadTokens,
|
||||
}
|
||||
}
|
||||
99
server/internal/daemon/usage/hermes_test.go
Normal file
99
server/internal/daemon/usage/hermes_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
154
server/internal/daemon/usage/openclaw.go
Normal file
154
server/internal/daemon/usage/openclaw.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package usage
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// scanOpenClaw reads OpenClaw JSONL session files from
|
||||
// ~/.openclaw/agents/*/sessions/*.jsonl
|
||||
// and extracts token usage from assistant message entries.
|
||||
func (s *Scanner) scanOpenClaw() []Record {
|
||||
root := openClawSessionRoot()
|
||||
if root == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Glob for session files: agents/*/sessions/*.jsonl
|
||||
pattern := filepath.Join(root, "*", "sessions", "*.jsonl")
|
||||
files, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
s.logger.Debug("openclaw glob error", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var allRecords []Record
|
||||
for _, f := range files {
|
||||
records := s.parseOpenClawFile(f)
|
||||
allRecords = append(allRecords, records...)
|
||||
}
|
||||
|
||||
return mergeRecords(allRecords)
|
||||
}
|
||||
|
||||
// openClawSessionRoot returns the OpenClaw agents directory.
|
||||
func openClawSessionRoot() string {
|
||||
if openclawHome := os.Getenv("OPENCLAW_HOME"); openclawHome != "" {
|
||||
dir := filepath.Join(openclawHome, "agents")
|
||||
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
dir := filepath.Join(home, ".openclaw", "agents")
|
||||
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||
return dir
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// openClawLine represents a line in an OpenClaw JSONL session file.
|
||||
type openClawLine struct {
|
||||
Type string `json:"type"`
|
||||
Timestamp string `json:"timestamp"` // RFC3339
|
||||
Message *struct {
|
||||
Role string `json:"role"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Usage *struct {
|
||||
Input int64 `json:"input"`
|
||||
Output int64 `json:"output"`
|
||||
CacheRead int64 `json:"cacheRead"`
|
||||
CacheWrite int64 `json:"cacheWrite"`
|
||||
} `json:"usage"`
|
||||
} `json:"message"`
|
||||
}
|
||||
|
||||
// parseOpenClawFile extracts token usage records from an OpenClaw session JSONL file.
|
||||
func (s *Scanner) parseOpenClawFile(path string) []Record {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var records []Record
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
// Fast pre-filter: skip lines that don't contain relevant data.
|
||||
if !bytesContains(line, `"usage"`) {
|
||||
continue
|
||||
}
|
||||
if !bytesContains(line, `"assistant"`) {
|
||||
continue
|
||||
}
|
||||
|
||||
var entry openClawLine
|
||||
if err := json.Unmarshal(line, &entry); err != nil {
|
||||
continue
|
||||
}
|
||||
if entry.Type != "message" || entry.Message == nil || entry.Message.Role != "assistant" || entry.Message.Usage == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
u := entry.Message.Usage
|
||||
if u.Input == 0 && u.Output == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse timestamp to get date.
|
||||
ts, err := time.Parse(time.RFC3339Nano, entry.Timestamp)
|
||||
if err != nil {
|
||||
ts, err = time.Parse(time.RFC3339, entry.Timestamp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
model := entry.Message.Model
|
||||
if model == "" {
|
||||
model = "unknown"
|
||||
}
|
||||
|
||||
// Construct provider string: if the session has a provider, use "openclaw/<provider>"
|
||||
// for attribution, but the Record.Provider field should be "openclaw".
|
||||
provider := "openclaw"
|
||||
_ = entry.Message.Provider // available but not used in provider field
|
||||
|
||||
records = append(records, Record{
|
||||
Date: ts.Local().Format("2006-01-02"),
|
||||
Provider: provider,
|
||||
Model: normalizeOpenClawModel(entry.Message.Provider, model),
|
||||
InputTokens: u.Input,
|
||||
OutputTokens: u.Output,
|
||||
CacheReadTokens: u.CacheRead,
|
||||
CacheWriteTokens: u.CacheWrite,
|
||||
})
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
// normalizeOpenClawModel returns a model identifier. If the provider is known,
|
||||
// it prefixes the model name for clarity (e.g. "deepseek/deepseek-chat").
|
||||
func normalizeOpenClawModel(provider, model string) string {
|
||||
provider = strings.TrimSpace(provider)
|
||||
model = strings.TrimSpace(model)
|
||||
if provider != "" && !strings.Contains(model, "/") {
|
||||
return provider + "/" + model
|
||||
}
|
||||
return model
|
||||
}
|
||||
93
server/internal/daemon/usage/openclaw_test.go
Normal file
93
server/internal/daemon/usage/openclaw_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package usage
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseOpenClawFile(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
// Real OpenClaw session JSONL with session header, model_change, and assistant messages
|
||||
content := `{"type":"session","version":3,"id":"multica-test","timestamp":"2026-04-11T13:53:05.847Z"}
|
||||
{"type":"model_change","id":"03c18aae","timestamp":"2026-04-11T13:53:05.855Z","provider":"deepseek","modelId":"deepseek-chat"}
|
||||
{"type":"message","id":"162ce1b7","parentId":"c90ecabe","timestamp":"2026-04-11T13:53:09.986Z","message":{"role":"assistant","content":[{"type":"text","text":"I'll start by getting the issue details."}],"api":"openai-completions","provider":"deepseek","model":"deepseek-chat","usage":{"input":133,"output":81,"cacheRead":16448,"cacheWrite":0,"totalTokens":16662}}}
|
||||
{"type":"message","id":"3c063300","parentId":"50e4feb6","timestamp":"2026-04-11T13:53:14.750Z","message":{"role":"assistant","content":[{"type":"text","text":"Let me check the workspace."}],"provider":"deepseek","model":"deepseek-chat","usage":{"input":286,"output":94,"cacheRead":16448,"cacheWrite":0}}}
|
||||
{"type":"message","id":"user001","timestamp":"2026-04-11T13:54:00.000Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}
|
||||
`
|
||||
|
||||
filePath := filepath.Join(tmp, "session.jsonl")
|
||||
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := NewScanner(slog.Default())
|
||||
records := s.parseOpenClawFile(filePath)
|
||||
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("expected 2 records, got %d", len(records))
|
||||
}
|
||||
|
||||
r := records[0]
|
||||
if r.Provider != "openclaw" {
|
||||
t.Errorf("provider = %q, want %q", r.Provider, "openclaw")
|
||||
}
|
||||
if r.Model != "deepseek/deepseek-chat" {
|
||||
t.Errorf("model = %q, want %q", r.Model, "deepseek/deepseek-chat")
|
||||
}
|
||||
if r.InputTokens != 133 {
|
||||
t.Errorf("input_tokens = %d, want %d", r.InputTokens, 133)
|
||||
}
|
||||
if r.OutputTokens != 81 {
|
||||
t.Errorf("output_tokens = %d, want %d", r.OutputTokens, 81)
|
||||
}
|
||||
if r.CacheReadTokens != 16448 {
|
||||
t.Errorf("cache_read_tokens = %d, want %d", r.CacheReadTokens, 16448)
|
||||
}
|
||||
if r.Date != "2026-04-11" {
|
||||
t.Errorf("date = %q, want %q", r.Date, "2026-04-11")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOpenClawFile_NoUsage(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
// Session with no assistant messages containing usage
|
||||
content := `{"type":"session","version":3,"id":"empty-session","timestamp":"2026-04-11T13:53:05.847Z"}
|
||||
{"type":"message","id":"user001","timestamp":"2026-04-11T13:54:00.000Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}
|
||||
`
|
||||
|
||||
filePath := filepath.Join(tmp, "session.jsonl")
|
||||
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := NewScanner(slog.Default())
|
||||
records := s.parseOpenClawFile(filePath)
|
||||
|
||||
if len(records) != 0 {
|
||||
t.Errorf("expected 0 records, got %d", len(records))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenClawModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
provider string
|
||||
model string
|
||||
want string
|
||||
}{
|
||||
{"deepseek", "deepseek-chat", "deepseek/deepseek-chat"},
|
||||
{"anthropic", "claude-sonnet-4-5", "anthropic/claude-sonnet-4-5"},
|
||||
{"", "gpt-4o", "gpt-4o"},
|
||||
{"openai", "openai/gpt-4o", "openai/gpt-4o"}, // already has /
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := normalizeOpenClawModel(tt.provider, tt.model)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeOpenClawModel(%q, %q) = %q, want %q", tt.provider, tt.model, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
122
server/internal/daemon/usage/opencode.go
Normal file
122
server/internal/daemon/usage/opencode.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package usage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// scanOpenCode reads OpenCode message JSON files from
|
||||
// ~/.local/share/opencode/storage/message/ses_*/*.json
|
||||
// and extracts token usage from assistant messages.
|
||||
func (s *Scanner) scanOpenCode() []Record {
|
||||
root := openCodeStorageRoot()
|
||||
if root == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Glob for message files: storage/message/ses_*/*.json
|
||||
pattern := filepath.Join(root, "ses_*", "*.json")
|
||||
files, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
s.logger.Debug("opencode glob error", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var allRecords []Record
|
||||
for _, f := range files {
|
||||
record := s.parseOpenCodeFile(f)
|
||||
if record != nil {
|
||||
allRecords = append(allRecords, *record)
|
||||
}
|
||||
}
|
||||
|
||||
return mergeRecords(allRecords)
|
||||
}
|
||||
|
||||
// openCodeStorageRoot returns the OpenCode message storage directory.
|
||||
func openCodeStorageRoot() string {
|
||||
// Check XDG_DATA_HOME first, then fall back to ~/.local/share
|
||||
dataHome := os.Getenv("XDG_DATA_HOME")
|
||||
if dataHome == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
dataHome = filepath.Join(home, ".local", "share")
|
||||
}
|
||||
|
||||
dir := filepath.Join(dataHome, "opencode", "storage", "message")
|
||||
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||
return dir
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// openCodeMessage represents the subset of an OpenCode message JSON file we need.
|
||||
type openCodeMessage struct {
|
||||
Role string `json:"role"`
|
||||
ModelID string `json:"modelID"`
|
||||
ProviderID string `json:"providerID"`
|
||||
Time *struct {
|
||||
Created int64 `json:"created"` // unix milliseconds
|
||||
} `json:"time"`
|
||||
Tokens *struct {
|
||||
Input int64 `json:"input"`
|
||||
Output int64 `json:"output"`
|
||||
Reasoning int64 `json:"reasoning"`
|
||||
Cache *struct {
|
||||
Read int64 `json:"read"`
|
||||
Write int64 `json:"write"`
|
||||
} `json:"cache"`
|
||||
} `json:"tokens"`
|
||||
}
|
||||
|
||||
// parseOpenCodeFile reads a single OpenCode message JSON file and returns a Record
|
||||
// if it contains assistant token usage. Returns nil otherwise.
|
||||
func (s *Scanner) parseOpenCodeFile(path string) *Record {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var msg openCodeMessage
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only count assistant messages with token usage.
|
||||
if msg.Role != "assistant" || msg.Tokens == nil || msg.Time == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip messages with no meaningful token usage.
|
||||
if msg.Tokens.Input == 0 && msg.Tokens.Output == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ts := time.UnixMilli(msg.Time.Created)
|
||||
date := ts.Local().Format("2006-01-02")
|
||||
|
||||
model := msg.ModelID
|
||||
if model == "" {
|
||||
model = "unknown"
|
||||
}
|
||||
|
||||
var cacheRead, cacheWrite int64
|
||||
if msg.Tokens.Cache != nil {
|
||||
cacheRead = msg.Tokens.Cache.Read
|
||||
cacheWrite = msg.Tokens.Cache.Write
|
||||
}
|
||||
|
||||
return &Record{
|
||||
Date: date,
|
||||
Provider: "opencode",
|
||||
Model: model,
|
||||
InputTokens: msg.Tokens.Input,
|
||||
OutputTokens: msg.Tokens.Output + msg.Tokens.Reasoning,
|
||||
CacheReadTokens: cacheRead,
|
||||
CacheWriteTokens: cacheWrite,
|
||||
}
|
||||
}
|
||||
141
server/internal/daemon/usage/opencode_test.go
Normal file
141
server/internal/daemon/usage/opencode_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package usage
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseOpenCodeFile(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
// Real OpenCode message JSON format with token usage
|
||||
content := `{
|
||||
"id": "msg_test001",
|
||||
"sessionID": "ses_test001",
|
||||
"role": "assistant",
|
||||
"time": {"created": 1768332037518, "completed": 1768332039410},
|
||||
"modelID": "claude-sonnet-4-5",
|
||||
"providerID": "anthropic",
|
||||
"tokens": {"input": 10916, "output": 5, "reasoning": 100, "cache": {"read": 448, "write": 50}}
|
||||
}`
|
||||
|
||||
filePath := filepath.Join(tmp, "msg_test001.json")
|
||||
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := NewScanner(slog.Default())
|
||||
record := s.parseOpenCodeFile(filePath)
|
||||
|
||||
if record == nil {
|
||||
t.Fatal("expected non-nil record")
|
||||
}
|
||||
|
||||
if record.Provider != "opencode" {
|
||||
t.Errorf("provider = %q, want %q", record.Provider, "opencode")
|
||||
}
|
||||
if record.Model != "claude-sonnet-4-5" {
|
||||
t.Errorf("model = %q, want %q", record.Model, "claude-sonnet-4-5")
|
||||
}
|
||||
if record.InputTokens != 10916 {
|
||||
t.Errorf("input_tokens = %d, want %d", record.InputTokens, 10916)
|
||||
}
|
||||
// output_tokens + reasoning
|
||||
if record.OutputTokens != 105 {
|
||||
t.Errorf("output_tokens = %d, want %d", record.OutputTokens, 105)
|
||||
}
|
||||
if record.CacheReadTokens != 448 {
|
||||
t.Errorf("cache_read_tokens = %d, want %d", record.CacheReadTokens, 448)
|
||||
}
|
||||
if record.CacheWriteTokens != 50 {
|
||||
t.Errorf("cache_write_tokens = %d, want %d", record.CacheWriteTokens, 50)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOpenCodeFile_UserMessage(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
// User messages should be ignored (no token usage to report)
|
||||
content := `{
|
||||
"id": "msg_user001",
|
||||
"sessionID": "ses_test001",
|
||||
"role": "user",
|
||||
"time": {"created": 1768332037000}
|
||||
}`
|
||||
|
||||
filePath := filepath.Join(tmp, "msg_user001.json")
|
||||
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := NewScanner(slog.Default())
|
||||
record := s.parseOpenCodeFile(filePath)
|
||||
|
||||
if record != nil {
|
||||
t.Errorf("expected nil record for user message, got %+v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOpenCodeFile_NoCache(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
// Message without cache field
|
||||
content := `{
|
||||
"id": "msg_test002",
|
||||
"sessionID": "ses_test002",
|
||||
"role": "assistant",
|
||||
"time": {"created": 1768332037518},
|
||||
"modelID": "gpt-4o",
|
||||
"providerID": "openai",
|
||||
"tokens": {"input": 500, "output": 200, "reasoning": 0}
|
||||
}`
|
||||
|
||||
filePath := filepath.Join(tmp, "msg_test002.json")
|
||||
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := NewScanner(slog.Default())
|
||||
record := s.parseOpenCodeFile(filePath)
|
||||
|
||||
if record == nil {
|
||||
t.Fatal("expected non-nil record")
|
||||
}
|
||||
if record.CacheReadTokens != 0 {
|
||||
t.Errorf("cache_read_tokens = %d, want 0", record.CacheReadTokens)
|
||||
}
|
||||
if record.CacheWriteTokens != 0 {
|
||||
t.Errorf("cache_write_tokens = %d, want 0", record.CacheWriteTokens)
|
||||
}
|
||||
if record.Model != "gpt-4o" {
|
||||
t.Errorf("model = %q, want %q", record.Model, "gpt-4o")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOpenCodeFile_ZeroTokens(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
// Message with zero tokens should return nil
|
||||
content := `{
|
||||
"id": "msg_test003",
|
||||
"sessionID": "ses_test003",
|
||||
"role": "assistant",
|
||||
"time": {"created": 1768332037518},
|
||||
"modelID": "test-model",
|
||||
"tokens": {"input": 0, "output": 0, "reasoning": 0}
|
||||
}`
|
||||
|
||||
filePath := filepath.Join(tmp, "msg_test003.json")
|
||||
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := NewScanner(slog.Default())
|
||||
record := s.parseOpenCodeFile(filePath)
|
||||
|
||||
if record != nil {
|
||||
t.Errorf("expected nil record for zero tokens, got %+v", record)
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,9 @@ func NewScanner(logger *slog.Logger) *Scanner {
|
||||
return &Scanner{logger: logger}
|
||||
}
|
||||
|
||||
// Scan reads local JSONL log files for both Claude Code and Codex CLI,
|
||||
// and returns aggregated usage records keyed by (date, provider, model).
|
||||
// Scan reads local log files for all supported agent runtimes (Claude Code,
|
||||
// Codex, OpenCode, OpenClaw, Hermes) and returns aggregated usage records
|
||||
// keyed by (date, provider, model).
|
||||
func (s *Scanner) Scan() []Record {
|
||||
var records []Record
|
||||
|
||||
@@ -36,6 +37,15 @@ func (s *Scanner) Scan() []Record {
|
||||
codexRecords := s.scanCodex()
|
||||
records = append(records, codexRecords...)
|
||||
|
||||
openCodeRecords := s.scanOpenCode()
|
||||
records = append(records, openCodeRecords...)
|
||||
|
||||
openClawRecords := s.scanOpenClaw()
|
||||
records = append(records, openClawRecords...)
|
||||
|
||||
hermesRecords := s.scanHermes()
|
||||
records = append(records, hermesRecords...)
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user