mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +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.
123 lines
3.0 KiB
Go
123 lines
3.0 KiB
Go
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,
|
|
}
|
|
}
|