The parser read `payload.msg` but Codex JSONL files store token data at `payload.info`. Also adds model tracking from `turn_context` events, `last_token_usage` fallback, and `cache_read_input_tokens` field support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
178 lines
4.5 KiB
Go
178 lines
4.5 KiB
Go
package usage
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// scanCodex reads Codex CLI session logs from ~/.codex/sessions/YYYY/MM/DD/*.jsonl
|
|
// and extracts token usage from "token_count" event lines.
|
|
func (s *Scanner) scanCodex() []Record {
|
|
root := codexLogRoot()
|
|
if root == "" {
|
|
return nil
|
|
}
|
|
|
|
// Glob for session files: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
|
|
pattern := filepath.Join(root, "*", "*", "*", "*.jsonl")
|
|
files, err := filepath.Glob(pattern)
|
|
if err != nil {
|
|
s.logger.Debug("codex glob error", "error", err)
|
|
return nil
|
|
}
|
|
|
|
var allRecords []Record
|
|
for _, f := range files {
|
|
record := s.parseCodexFile(f)
|
|
if record != nil {
|
|
allRecords = append(allRecords, *record)
|
|
}
|
|
}
|
|
|
|
return mergeRecords(allRecords)
|
|
}
|
|
|
|
// codexLogRoot returns the Codex sessions directory.
|
|
func codexLogRoot() string {
|
|
if codexHome := os.Getenv("CODEX_HOME"); codexHome != "" {
|
|
dir := filepath.Join(codexHome, "sessions")
|
|
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
|
return dir
|
|
}
|
|
}
|
|
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
dir := filepath.Join(home, ".codex", "sessions")
|
|
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
|
return dir
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// codexEvent represents a line in a Codex session JSONL file.
|
|
type codexEvent struct {
|
|
Type string `json:"type"`
|
|
Payload *codexPayload `json:"payload"`
|
|
}
|
|
|
|
type codexPayload struct {
|
|
Type string `json:"type"`
|
|
Info *codexTokenInfo `json:"info"`
|
|
Model string `json:"model"` // present in turn_context events
|
|
}
|
|
|
|
type codexTokenInfo struct {
|
|
TotalTokenUsage *codexTokenUsage `json:"total_token_usage"`
|
|
LastTokenUsage *codexTokenUsage `json:"last_token_usage"`
|
|
Model string `json:"model"`
|
|
}
|
|
|
|
type codexTokenUsage struct {
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
CachedInputTokens int64 `json:"cached_input_tokens"`
|
|
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
|
|
ReasoningOutputTokens int64 `json:"reasoning_output_tokens"`
|
|
TotalTokens int64 `json:"total_tokens"`
|
|
}
|
|
|
|
// parseCodexFile extracts the final cumulative token_count from a Codex session file.
|
|
// Returns nil if no usage data found.
|
|
func (s *Scanner) parseCodexFile(path string) *Record {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer f.Close()
|
|
|
|
// Extract date from directory path: .../sessions/YYYY/MM/DD/file.jsonl
|
|
date := extractDateFromPath(path)
|
|
if date == "" {
|
|
return nil
|
|
}
|
|
|
|
var lastUsage *codexTokenUsage
|
|
var lastModel string
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024)
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
|
|
// Fast pre-filter: only parse lines with token_count or turn_context
|
|
hasTokenCount := bytesContains(line, `"token_count"`)
|
|
hasTurnContext := bytesContains(line, `"turn_context"`)
|
|
if !hasTokenCount && !hasTurnContext {
|
|
continue
|
|
}
|
|
|
|
var evt codexEvent
|
|
if err := json.Unmarshal(line, &evt); err != nil || evt.Payload == nil {
|
|
continue
|
|
}
|
|
|
|
// Track model from turn_context events
|
|
if evt.Type == "turn_context" && evt.Payload.Model != "" {
|
|
lastModel = evt.Payload.Model
|
|
continue
|
|
}
|
|
|
|
// Extract token usage from token_count events
|
|
if evt.Payload.Type == "token_count" && evt.Payload.Info != nil {
|
|
usage := evt.Payload.Info.TotalTokenUsage
|
|
if usage == nil {
|
|
usage = evt.Payload.Info.LastTokenUsage
|
|
}
|
|
if usage != nil {
|
|
lastUsage = usage
|
|
if evt.Payload.Info.Model != "" {
|
|
lastModel = evt.Payload.Info.Model
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if lastUsage == nil {
|
|
return nil
|
|
}
|
|
|
|
model := lastModel
|
|
if model == "" {
|
|
model = "unknown"
|
|
}
|
|
|
|
cachedTokens := lastUsage.CachedInputTokens
|
|
if cachedTokens == 0 {
|
|
cachedTokens = lastUsage.CacheReadInputTokens
|
|
}
|
|
|
|
return &Record{
|
|
Date: date,
|
|
Provider: "codex",
|
|
Model: model,
|
|
InputTokens: lastUsage.InputTokens,
|
|
OutputTokens: lastUsage.OutputTokens + lastUsage.ReasoningOutputTokens,
|
|
CacheReadTokens: cachedTokens,
|
|
CacheWriteTokens: 0,
|
|
}
|
|
}
|
|
|
|
// extractDateFromPath extracts YYYY-MM-DD from a path like .../sessions/2026/03/26/file.jsonl
|
|
func extractDateFromPath(path string) string {
|
|
parts := strings.Split(filepath.ToSlash(path), "/")
|
|
// Look for sessions/YYYY/MM/DD pattern
|
|
for i := 0; i < len(parts)-3; i++ {
|
|
if parts[i] == "sessions" && len(parts[i+1]) == 4 && len(parts[i+2]) == 2 && len(parts[i+3]) == 2 {
|
|
return parts[i+1] + "-" + parts[i+2] + "-" + parts[i+3]
|
|
}
|
|
}
|
|
return ""
|
|
}
|