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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,10 @@ ONWATCH_ADMIN_PASS=changeme
# Each file rotates at 50MB with 3 backups (.1, .2, .3)
# In debug mode (--debug), logs go to stdout
ONWATCH_LOG_LEVEL=info

# Kimi Code (auto-detected from ~/.kimi-code/credentials/kimi-code.json)
# KIMI_CODE_ENABLED=true
# KIMI_TOKEN= # optional static access token (Docker); prefer local OAuth
# KIMI_CODE_CREDENTIALS=~/.kimi-code/credentials/kimi-code.json
# KIMI_CODE_BASE_URL=https://api.kimi.com/coding/v1
# KIMI_CODE_OAUTH_HOST=https://auth.kimi.com
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Free, open-source AI API quota monitoring for developers.**

Track usage across [Synthetic](https://synthetic.new), [Z.ai](https://z.ai), [Anthropic](https://anthropic.com), [Codex](https://openai.com/codex), [GitHub Copilot](https://github.com/features/copilot), [MiniMax](https://platform.minimax.io), [Gemini CLI](docs/GEMINI_SETUP.md), [Cursor](docs/CURSOR_SETUP.md), [Grok](docs/GROK_SETUP.md), and Antigravity in one place.
Track usage across [Synthetic](https://synthetic.new), [Z.ai](https://z.ai), [Anthropic](https://anthropic.com), [Codex](https://openai.com/codex), [GitHub Copilot](https://github.com/features/copilot), [MiniMax](https://platform.minimax.io), [Gemini CLI](docs/GEMINI_SETUP.md), [Cursor](docs/CURSOR_SETUP.md), [Grok](docs/GROK_SETUP.md), [Kimi Code](docs/KIMI_SETUP.md), and Antigravity in one place.
See history, get alerts, and open a local web dashboard before you hit throttling or run over budget. Additionally, you can ingest local telemetry from your own API-driven workflows with API Integrations, keeping track of token use and spending across multiple providers.

**Links:** [Website](https://onwatch.onllm.dev) | [Buy Me a Coffee](https://buymeacoffee.com/prakersh)
Expand Down Expand Up @@ -168,6 +168,7 @@ Open **http://localhost:9211** and log in with your `.env` credentials.
- **Gemini CLI (Beta)** -- Per-model quota tracking for Gemini 2.5/3.x Pro, Flash, and Flash Lite models with 24-hour reset cycles
- **Antigravity** -- Multi-model quota cards (Claude, Gemini, GPT) with grouped quota pools, logging history, and cycle overview. Selectable data **source** -- the desktop **IDE** probe or the **`agy` CLI** (richer weekly + 5-hour buckets), or **both** (default) -- switchable in the dashboard settings; all variants share one Google-account quota
- **Cursor** -- Individual, Team, and Enterprise account tracking with auto-detected credentials from Cursor Desktop SQLite or macOS Keychain/Linux keyring, OAuth token auto-refresh, burn rate forecasts, and on-demand spend tracking
- **Kimi Code** -- Moonshot Kimi Code CLI OAuth quotas via `GET /coding/v1/usages` (auto-detect `~/.kimi-code/credentials`). Weekly + window limits with reset countdown. See [Kimi Setup](docs/KIMI_SETUP.md).
- **Grok** -- xAI Grok Build / SuperGrok credits tracking via local `~/.grok/auth.json` (or `$GROK_HOME`), optional `grok agent stdio` RPC, and grok.com gRPC-web bearer probe (no browser cookie import). Primary "Credits" utilization against plan limit with reset countdown. Informational local session token stats also captured.
- **API Integrations** -- Local JSONL ingestion for custom API-driven workflows and automations. Track per-integration token volume, request counts, recent activity, costs, trends, and accumulated usage across separate API keys and providers.
- **All** -- Side-by-side view of all configured providers
Expand Down Expand Up @@ -333,6 +334,9 @@ Additional environment variables:
| `GROK_TOKEN` | Grok bearer from `grok login` (or auto-detected from ~/.grok/auth.json)|
| `GROK_ENABLED` | Enable Grok provider (default: auto when auth present; set false to disable)|
| `GROK_HOME` | Custom Grok home dir (default ~/.grok; auth.json and sessions live here)|
| `KIMI_TOKEN` / `KIMI_CODE_TOKEN` | Optional static Kimi Code access token (prefer local OAuth credentials)|
| `KIMI_CODE_ENABLED` | Enable Kimi Code provider (default: auto when credentials present)|
| `KIMI_CODE_CREDENTIALS` | Path to kimi-code.json (default ~/.kimi-code/credentials/kimi-code.json)|
| `ANTIGRAVITY_ENABLED` | Enable Antigravity provider (auto-detects local server)|
| `ANTIGRAVITY_SOURCE` | Data source: `both` (default), `cli` (agy), or `ide` |
| `ANTIGRAVITY_CLI_PATH` | Override path to the `agy` binary (else PATH/well-known)|
Expand Down
99 changes: 99 additions & 0 deletions docs/KIMI_SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Kimi Code Setup

onWatch tracks **Kimi Code** (the coding agent OAuth product) quotas via:

```http
GET https://api.kimi.com/coding/v1/usages
Authorization: Bearer <access_token>
```

This is **not** the Moonshot Open Platform pay-as-you-go balance API (`api.moonshot.ai` / `api.moonshot.cn`).

## Prerequisites

1. Install and log in with **kimi-code** only: [docs](https://moonshotai.github.io/kimi-code/) — `kimi login`
2. Credentials file is searched **only** under the kimi-code store (in order):
- `$KIMI_CODE_CREDENTIALS` or `$KIMI_CREDENTIALS` (explicit file; Docker/CI)
- `$KIMI_CODE_HOME/credentials/kimi-code.json`
- `~/.kimi-code/credentials/kimi-code.json`

**Legacy kimi-cli (`~/.kimi`, `$KIMI_SHARE_DIR`, `$KIMI_HOME`) is not supported.**
A single dashboard tab must not touch two OAuth token chains (refresh rotation would invalidate the other store).

### Token refresh policy

- If the kimi-code **access token is still valid** (`expires_at` with a 60s skew), onWatch **never** refreshes — it reuses the token the CLI already wrote.
- Refresh runs only when access is **expired**. Then onWatch may call:

```http
POST https://auth.kimi.com/api/oauth/token
grant_type=refresh_token&refresh_token=...&client_id=17e5f671-d194-4dfb-9706-5516cb48c098
```

and rewrite **the same kimi-code credentials file** (mode `0600`).

- On HTTP 401 with a still-unexpired access token, onWatch re-reads disk once (CLI may have rotated tokens) but **does not** force-refresh.

## Enable

Auto-detect is on by default when credentials exist:

```bash
# optional explicit enable
KIMI_CODE_ENABLED=true

# optional disable
KIMI_CODE_ENABLED=false
```

Docker / CI without local files:

```bash
KIMI_TOKEN=<access_token>
# or
KIMI_CODE_TOKEN=<access_token>
```

For long-running daemons, prefer mounting the kimi-code credentials file:

```bash
KIMI_CODE_CREDENTIALS=/path/to/kimi-code.json
```

## What is tracked

Dashboard quota cards (same rate-limit surface as Code CLI):

| Card | Source | Meaning |
|------|--------|---------|
| **7-day** | `usage` | 7-day utilization (`used/limit`). Product UI may show one decimal place; the API usually returns integer percents. |
| **5-hour** | `limits[]` with `duration=300` + `TIME_UNIT_MINUTE` | Rolling 5-hour window |

Insights also shows **Membership** plan name from `user.membership.level`:

| API level | Display name |
|-----------|--------------|
| `LEVEL_FREE` | Free |
| `LEVEL_BASIC` | Adagio |
| `LEVEL_STANDARD` | Moderato |
| `LEVEL_INTERMEDIATE` | Allegretto |
| `LEVEL_ADVANCED` | Allegro |
| `LEVEL_PREMIUM` | Vivace |

Other `/usages` fields (`totalQuota`, non-5h windows) are ignored. The membership site “total usage” bar (e.g. on [My Quota](https://www.kimi.com/membership/subscription?tab=quota)) comes from a separate web API (`GetSubscriptionStats`) and is **not** tracked.

### Timezones

`resetTime` values are UTC. The dashboard formats them in your configured timezone (Settings). Example: `2026-07-14T16:13:41Z` → `2026-07-15 00:13` in Asia/Shanghai.

## Verify

```bash
# after kimi-code login
kimi # then check usage in the CLI if available

# or curl with the access_token from ~/.kimi-code/credentials/kimi-code.json
curl -sS -H "Authorization: Bearer $TOKEN" https://api.kimi.com/coding/v1/usages | jq .
```

Restart onWatch and open the **Kimi Code** dashboard tab.
140 changes: 140 additions & 0 deletions internal/agent/kimi_agent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package agent

import (
"context"
"log/slog"
"time"

"github.com/onllm-dev/onwatch/v2/internal/api"
"github.com/onllm-dev/onwatch/v2/internal/notify"
"github.com/onllm-dev/onwatch/v2/internal/store"
"github.com/onllm-dev/onwatch/v2/internal/tracker"
)

// KimiAgent manages the background polling loop for Kimi Code quotas.
type KimiAgent struct {
client *api.KimiClient
store *store.Store
tracker *tracker.KimiTracker
interval time.Duration
logger *slog.Logger
sm *SessionManager
notifier *notify.NotificationEngine
pollingCheck func() bool
}

// SetPollingCheck sets a function that is called before each poll.
func (a *KimiAgent) SetPollingCheck(fn func() bool) {
a.pollingCheck = fn
}

// SetNotifier sets the notification engine for sending alerts.
func (a *KimiAgent) SetNotifier(n *notify.NotificationEngine) {
a.notifier = n
}

// NewKimiAgent creates a new KimiAgent.
func NewKimiAgent(client *api.KimiClient, store *store.Store, tr *tracker.KimiTracker, interval time.Duration, logger *slog.Logger, sm *SessionManager) *KimiAgent {
if logger == nil {
logger = slog.Default()
}
return &KimiAgent{
client: client,
store: store,
tracker: tr,
interval: interval,
logger: logger,
sm: sm,
}
}

// Run starts the Kimi agent's polling loop.
func (a *KimiAgent) Run(ctx context.Context) error {
a.logger.Info("Kimi Code agent started", "interval", a.interval)

defer func() {
if a.sm != nil {
a.sm.Close()
}
a.logger.Info("Kimi Code agent stopped")
}()

a.poll(ctx)

ticker := time.NewTicker(a.interval)
defer ticker.Stop()

for {
select {
case <-ticker.C:
a.poll(ctx)
case <-ctx.Done():
return nil
}
}
}

func (a *KimiAgent) poll(ctx context.Context) {
if a.client == nil {
return
}
if a.pollingCheck != nil && !a.pollingCheck() {
return
}

snapshot, err := a.client.FetchSnapshot(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
a.logger.Error("Failed to fetch Kimi Code usages", "error", err)
return
}

if _, err := a.store.InsertKimiSnapshot(snapshot); err != nil {
a.logger.Error("Failed to insert Kimi snapshot", "error", err)
return
}

if a.tracker != nil {
if err := a.tracker.Process(snapshot); err != nil {
a.logger.Error("Kimi tracker processing failed", "error", err)
}
}

if a.notifier != nil && len(snapshot.Quotas) > 0 {
q := snapshot.Quotas[0]
a.notifier.Check(notify.QuotaStatus{
Provider: "kimi",
QuotaKey: q.Name,
Utilization: q.Utilization,
})
}

if a.sm != nil {
vals := make([]float64, len(snapshot.Quotas))
for i, q := range snapshot.Quotas {
vals[i] = q.Utilization
}
if len(vals) == 0 {
vals = []float64{0}
}
a.sm.ReportPoll(vals)
}

primaryUtil := 0.0
var resets interface{}
if len(snapshot.Quotas) > 0 {
primaryUtil = snapshot.Quotas[0].Utilization
if snapshot.Quotas[0].ResetsAt != nil {
resets = snapshot.Quotas[0].ResetsAt.Format(time.RFC3339)
}
}
a.logger.Info("Kimi Code poll complete",
"user_id", snapshot.UserID,
"membership", snapshot.Membership,
"quota_count", len(snapshot.Quotas),
"util", primaryUtil,
"resets", resets,
)
}
Loading
Loading