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
10 changes: 10 additions & 0 deletions cmd/tokitoki/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
Expand Down Expand Up @@ -596,8 +597,17 @@ func defaultLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
}

// exitNoAPIKey marks the one failure a caller can act on: no key is
// configured. Front-ends prompt for a key on this code and treat every other
// non-zero exit as a transient problem to log and retry, instead of guessing
// from the error text.
const exitNoAPIKey = 3

func fail(logger *slog.Logger, err error) int {
logger.Error("tokitoki failed", "error", err)
if errors.Is(err, agentlib.ErrMissingAPIKey) {
return exitNoAPIKey
}
return 1
}

Expand Down
8 changes: 5 additions & 3 deletions cmd/tokitoki/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,14 @@ func TestRunSetKeyRejectsMissingKey(t *testing.T) {
}
}

func TestRunGetKeyReturnsErrorWhenMissing(t *testing.T) {
// A missing key exits with its own code so front-ends can prompt for one
// without pattern-matching the error text.
func TestRunGetKeyReturnsNoAPIKeyCodeWhenMissing(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

if code := run([]string{"get", "key"}); code != 1 {
t.Fatalf("run(get key) = %d, want 1", code)
if code := run([]string{"get", "key"}); code != exitNoAPIKey {
t.Fatalf("run(get key) = %d, want %d", code, exitNoAPIKey)
}
}

Expand Down
156 changes: 124 additions & 32 deletions internal/agentusage/kimi.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import (
"path/filepath"
"sort"
"strings"
"time"

"github.com/tokitoki-dev/tokitoki-cli/internal/usage"
)

const kimiDefaultModel = "kimi-for-coding"

func loadKimiEntries(paths []string, filter usage.FileFilter) ([]usage.Entry, error) {
files := make([]string, 0)
for _, root := range paths {
Expand Down Expand Up @@ -40,67 +43,156 @@ func isKimiWireFile(path string) bool {
return false
}
parts := strings.Split(filepath.ToSlash(filepath.Clean(path)), "/")
for i := 0; i+3 < len(parts); i++ {
if parts[i] == "sessions" && i+3 == len(parts)-1 {
for i := range parts {
if parts[i] != "sessions" {
continue
}
// Old layout: sessions/<group>/<session>/wire.jsonl
// New layout: sessions/<workspace>/<session>/agents/<agent>/wire.jsonl
if i+3 == len(parts)-1 || i+5 == len(parts)-1 {
return true
}
}
return false
}

// kimiRecord is one usable wire line, normalized across the old
// StatusUpdate format and the new Kimi Code usage.record format.
type kimiRecord struct {
tokens usage.TokenUsage
timestamp time.Time
hasTime bool
model string // empty means fall back to the config.json model
messageID string
}

func parseKimiWireFile(path string) ([]usage.Entry, error) {
lines, err := readJSONLines(path, `"StatusUpdate"`, `"token_usage"`)
lines, err := readJSONLines(path, `usage`)
if err != nil {
return nil, err
}
model := kimiModel(path)
sessionID := filepath.Base(filepath.Dir(path))
if sessionID == "" || sessionID == "." {
sessionID = "unknown"
}
configModel := kimiConfigModel(path)
sessionID := kimiSessionID(path)
fallback := fileModifiedTime(path)
entries := make([]usage.Entry, 0)
for _, line := range lines {
message := objectAt(line.value["message"])
if stringField(message, "type") != "StatusUpdate" {
continue
var record kimiRecord
var ok bool
if stringField(line.value, "type") == "usage.record" {
record, ok = parseKimiUsageRecord(line.value)
} else {
record, ok = parseKimiStatusUpdate(line.value)
}
payload := objectAt(message["payload"])
tokenUsage := objectAt(payload["token_usage"])
if tokenUsage == nil {
if !ok {
continue
}
timestamp, ok := parseTimestamp(line.value["timestamp"])
if !ok {
timestamp := record.timestamp
if !record.hasTime {
timestamp = fallback
}
tokens := usage.TokenUsage{
InputTokens: uintField(tokenUsage, "input_other"),
OutputTokens: uintField(tokenUsage, "output"),
CacheCreationInputTokens: uintField(tokenUsage, "input_cache_creation"),
CacheReadInputTokens: uintField(tokenUsage, "input_cache_read"),
}
tokens = applyTotalFallback(tokens, uintField(tokenUsage, "total"))
if !nonZero(tokens) {
continue
model := record.model
if model == "" {
model = configModel
}
messageID := stringField(payload, "message_id")
entry := baseEntry(usage.ProviderKimi, timestamp, "kimi", "Kimi", sessionID, model, "Kimi", tokens)
entry := baseEntry(usage.ProviderKimi, timestamp, "kimi", "Kimi", sessionID, model, "Kimi", record.tokens)
setSource(&entry, path, line.line, line.start, line.end)
entry.ID = stableEntryID(entry, messageID)
entry.ID = stableEntryID(entry, record.messageID)
entries = append(entries, entry)
}
return entries, nil
}

func kimiModel(path string) string {
root := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(path))))
func parseKimiStatusUpdate(value map[string]any) (kimiRecord, bool) {
message := objectAt(value["message"])
if stringField(message, "type") != "StatusUpdate" {
return kimiRecord{}, false
}
payload := objectAt(message["payload"])
tokenUsage := objectAt(payload["token_usage"])
if tokenUsage == nil {
return kimiRecord{}, false
}
tokens := usage.TokenUsage{
InputTokens: uintField(tokenUsage, "input_other"),
OutputTokens: uintField(tokenUsage, "output"),
CacheCreationInputTokens: uintField(tokenUsage, "input_cache_creation"),
CacheReadInputTokens: uintField(tokenUsage, "input_cache_read"),
}
tokens = applyTotalFallback(tokens, uintField(tokenUsage, "total"))
if !nonZero(tokens) {
return kimiRecord{}, false
}
record := kimiRecord{tokens: tokens, messageID: stringField(payload, "message_id")}
record.timestamp, record.hasTime = parseTimestamp(value["timestamp"])
return record, true
}

func parseKimiUsageRecord(value map[string]any) (kimiRecord, bool) {
// Session-scoped records are cumulative totals; only turn records count.
if stringField(value, "usageScope") != "turn" {
return kimiRecord{}, false
}
tokenUsage := objectAt(value["usage"])
if tokenUsage == nil {
return kimiRecord{}, false
}
tokens := usage.TokenUsage{
InputTokens: uintField(tokenUsage, "inputOther"),
OutputTokens: uintField(tokenUsage, "output"),
CacheCreationInputTokens: uintField(tokenUsage, "inputCacheCreation"),
CacheReadInputTokens: uintField(tokenUsage, "inputCacheRead"),
}
tokens = applyTotalFallback(tokens, 0)
if !nonZero(tokens) {
return kimiRecord{}, false
}
record := kimiRecord{
tokens: tokens,
model: strings.TrimPrefix(stringField(value, "model"), "kimi-code/"),
}
record.timestamp, record.hasTime = parseTimestamp(value["time"])
return record, true
}

// kimiSessionID returns the session directory name for either layout.
func kimiSessionID(path string) string {
dir := filepath.Dir(path)
if filepath.Base(filepath.Dir(dir)) == "agents" {
dir = filepath.Dir(filepath.Dir(dir))
}
sessionID := filepath.Base(dir)
if sessionID == "" || sessionID == "." {
return "unknown"
}
return sessionID
}

// kimiRoot walks up from a wire file to the directory containing "sessions",
// which is the Kimi data root regardless of layout depth.
func kimiRoot(path string) string {
for dir := filepath.Dir(path); ; {
parent := filepath.Dir(dir)
if filepath.Base(dir) == "sessions" {
return parent
}
if parent == dir {
return ""
}
dir = parent
}
}

func kimiConfigModel(path string) string {
root := kimiRoot(path)
if root == "" {
return kimiDefaultModel
}
config, err := readJSONObject(filepath.Join(root, "config.json"))
if err != nil || config == nil {
return "kimi-for-coding"
return kimiDefaultModel
}
if model := stringField(config, "model"); model != "" {
return model
}
return "kimi-for-coding"
return kimiDefaultModel
}
22 changes: 22 additions & 0 deletions internal/agentusage/providers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,28 @@ func TestProvidersLoadEntries(t *testing.T) {
TotalTokens: 180,
},
},
{
name: "kimi-code",
provider: func() ([]usage.Entry, error) {
dir := t.TempDir()
path := filepath.Join(dir, "sessions", "wd_beeec_fe4684529abd", "session-b", "agents", "main", "wire.jsonl")
writeFile(t, path,
`{"type":"metadata","protocol_version":"1.4","created_at":1785336260355}`+"\n"+
`{"type":"usage.record","model":"kimi-code/kimi-for-coding","usage":{"inputOther":3064,"output":76,"inputCacheRead":14848,"inputCacheCreation":0},"usageScope":"turn","time":1782113184943}`+"\n"+
`{"type":"usage.record","model":"kimi-code/kimi-for-coding","usage":{"inputOther":5000,"output":200,"inputCacheRead":20000,"inputCacheCreation":100},"usageScope":"session","time":1782113185000}`+"\n")
return KimiProvider{}.WithPaths([]string{dir}).Entries()
},
want: usage.ProviderKimi,
model: "kimi-for-coding",
sessionID: "session-b",
project: "kimi",
tokens: usage.TokenUsage{
InputTokens: 3064,
OutputTokens: 76,
CacheReadInputTokens: 14848,
TotalTokens: 17988,
},
},
{
name: "qwen",
provider: func() ([]usage.Entry, error) {
Expand Down
10 changes: 8 additions & 2 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ import (
"github.com/tokitoki-dev/tokitoki-cli/internal/usageupload"
)

// ErrNoAPIKey reports that no API key is configured. Callers distinguish it
// from every other failure — a missing key is a thing the user fixes, a
// network or server error is not — so it travels as a sentinel rather than as
// text for someone to pattern-match on.
var ErrNoAPIKey = errors.New("API key is not configured in ~/.tokitoki/api_key")

type App struct {
Agent *agent.Agent
UsageDB *usagedb.DB
Expand All @@ -37,7 +43,7 @@ func (a *App) GetAPIKey() error {
return err
}
if settings.APIKey == "" {
return errors.New("API key is not configured in ~/.tokitoki/api_key")
return ErrNoAPIKey
}
_, err = fmt.Fprintf(a.Out, "%s\n", settings.APIKey)
return err
Expand Down Expand Up @@ -85,7 +91,7 @@ func (a *App) Upload(ctx context.Context) error {
return err
}
if settings.APIKey == "" {
return errors.New("API key is required in ~/.tokitoki/api_key")
return ErrNoAPIKey
}
if err := usageupload.SyncPending(ctx, settings, a.UsageDB); err != nil {
return err
Expand Down
10 changes: 5 additions & 5 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ package cli
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"strings"
"testing"

"github.com/tokitoki-dev/tokitoki-cli/internal/agent"
Expand Down Expand Up @@ -34,8 +34,8 @@ func TestIngestWorksWithoutAPIKey(t *testing.T) {
func TestUploadRequiresAPIKey(t *testing.T) {
app := newApp(t)
err := app.Upload(context.Background())
if err == nil || !strings.Contains(err.Error(), "API key is required") {
t.Fatalf("Upload() error = %v, want API key requirement", err)
if !errors.Is(err, ErrNoAPIKey) {
t.Fatalf("Upload() error = %v, want ErrNoAPIKey", err)
}
}

Expand Down Expand Up @@ -73,8 +73,8 @@ func TestGetAPIKeyWritesSavedKey(t *testing.T) {
func TestGetAPIKeyRequiresConfiguredKey(t *testing.T) {
app := newApp(t)
err := app.GetAPIKey()
if err == nil || !strings.Contains(err.Error(), "API key is not configured") {
t.Fatalf("GetAPIKey() error = %v, want missing key error", err)
if !errors.Is(err, ErrNoAPIKey) {
t.Fatalf("GetAPIKey() error = %v, want ErrNoAPIKey", err)
}
}

Expand Down
Loading