Skip to content
Open
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
71 changes: 71 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ bun run example
- **Context Length** / **Context Window** / **Context %** / **Context % (usable)** / **Context Bar** - Show current context length, total context window size, used/remaining percentage, usable-window percentage, or a progress bar.
- **Compaction Counter** - Show how many context compactions have been detected in the current session. It can render as icon plus number, text plus number, or number-only, and can hide while the count is zero.
- **Session Usage** / **Weekly Usage** / **Weekly Sonnet Usage** / **Weekly Opus Usage** / **Block Timer** / **Block Reset Timer** / **Weekly Reset Timer** - Show usage percentages plus current block/reset timing. The all-models weekly bar covers `seven_day` from the usage API; the per-model variants surface the `seven_day_sonnet` and `seven_day_opus` buckets that Claude Code's own `/usage` panel shows. Session and weekly usage bars can show a time cursor; reset timers can show remaining time, progress, or exact reset date/time with timezone and locale controls.
- **Proxy Budget** - Show spend vs configured budget from a LiteLLM-compatible proxy (LiteLLM, OpenRouter, Portkey, Helicone, …). Native widget with green/yellow/red threshold tiers and 60s disk cache; opt-in via metadata.

### Environment, Layout & Custom

Expand Down Expand Up @@ -231,6 +232,76 @@ Create clickable links in terminals that support OSC 8 hyperlinks:

> 📄 **How it works:** The command receives Claude Code's JSON data via stdin, allowing ccusage to access session information, model details, and transcript data for accurate usage tracking.

## Proxy Budget Widget

When Claude Code traffic is routed through a proxy (LiteLLM, OpenRouter, Portkey, Helicone, or anything that exposes a JSON spend/budget endpoint behind a bearer token), the Proxy Budget widget shows your current spend against the proxy-side cap with green/yellow/red threshold tiers.

The widget is configured entirely through `metadata` keys on the widget item. v1 ships two verified presets — `litellm` (the default) and `openrouter` — and lets you point at any other proxy with custom JSON paths.

### Presets

```jsonc
// LiteLLM (default — no preset key needed)
"metadata": { }

// OpenRouter (GET /api/v1/key)
"metadata": { "preset": "openrouter" }
```

Each preset bundles an endpoint suffix and the JSON paths for spend / cap / reset:

| Preset | Endpoint | Spend path | Cap path | Reset path |
|---|---|---|---|---|
| `litellm` (default) | `${baseUrl}/key/info` | `info.spend` | `info.max_budget` | `info.budget_reset_at` |
| `openrouter` | `${baseUrl}/api/v1/key` | `data.usage` | `data.limit` | `data.limit_reset` |

### Configuration

All settings live in `metadata` (strings; defaults applied at read time):

| Key | Default | Description |
|---|---|---|
| `preset` | _(unset → litellm)_ | One of `litellm`, `openrouter`. Sets endpoint suffix + JSON paths. User-supplied per-key values override the preset. |
| `endpoint` | preset default | Full URL or template with `${baseUrl}` placeholder. |
| `baseUrlEnv` | `ANTHROPIC_BASE_URL` | Env var holding the proxy base URL. Same env var Claude Code already uses to route, so the typical setup works out of the box. |
| `tokenEnv` | `ANTHROPIC_AUTH_TOKEN` | Env var holding the bearer token. Value never enters the config file or the disk cache. |
| `authScheme` | preset default (`bearer`) | `bearer` (Authorization header) or `x-api-key`. |
| `spendPath` | preset default | Dotted JSON path to current spend. |
| `budgetPath` | preset default | Dotted JSON path to the budget cap. |
| `resetAtPath` | preset default | Dotted JSON path to the ISO-8601 reset timestamp. |
| `warningThreshold` | `80` | Percent at which the segment turns yellow. |
| `criticalThreshold` | `95` | Percent at which the segment turns red. |
| `format` | `spend-percent` | `spend-percent` (`$5.00/$100.00 (5%)`), `percent` (`5%`), or `spend` (`$5.00`). |
| `cacheTtlSec` | `60` | Disk-cache TTL for proxy responses. |
| `timeoutMs` | `3000` | HTTP timeout. On timeout, non-2xx, or parse error the widget falls back to a stale cache (up to `10 ×` TTL old), then hides. |

### Example

For a LiteLLM proxy already wired into Claude Code via `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN`, no env vars or extra config are needed — add the widget and enable it.

For OpenRouter direct, set:

```sh
export ANTHROPIC_BASE_URL="https://openrouter.ai"
export ANTHROPIC_AUTH_TOKEN="sk-or-v1-..."
```

and configure `"metadata": { "preset": "openrouter" }`.

### Why a native widget vs Custom Command?

The Custom Command widget can run a script that hits the same endpoint, but:

- Spawns a separate shell process per render (~1–3s).
- Cache, lock-file, and timeout handling have to be reimplemented in the user's script for every install.
- Threshold-aware coloring, raw-value mode, preview mode, and editor metadata become script bookkeeping rather than first-class widget features.

The native widget pays a one-time integration cost for repeated correctness.

### Out of scope (v1)

Cloud-native cost APIs that don't fit the bearer-token-JSON pattern (AWS Bedrock SigV4 + Cost Explorer, Vertex AI service-account JWT + Cloud Billing, Azure RBAC + Cost Management) need different auth and a multi-call shape. Use this widget for proxies; cloud-native cost reporting is a separate concern.

## Integration Example: AIWatch

[AIWatch](https://ai-watch.dev) monitors the live status of 30+ AI APIs and apps (Claude, GPT, Gemini, …). Surfacing it in your status line answers "is Claude slow because of me, or because the API is degraded?" without leaving the terminal.
Expand Down
7 changes: 6 additions & 1 deletion src/ccstatusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ import {
getWidgetSpeedWindowSeconds,
isWidgetSpeedWindowEnabled
} from './utils/speed-window';
import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch';
import {
prefetchProxyBudgetIfNeeded,
prefetchUsageDataIfNeeded
} from './utils/usage-prefetch';

function hasSessionDurationInStatusJson(data: StatusJSON): boolean {
const durationMs = data.cost?.total_duration_ms;
Expand Down Expand Up @@ -127,6 +130,7 @@ async function renderMultipleLines(data: StatusJSON) {
}

const usageData = await prefetchUsageDataIfNeeded(lines, data);
const proxyBudgetData = await prefetchProxyBudgetIfNeeded(lines);

let speedMetrics: SpeedMetrics | null = null;
let windowedSpeedMetrics: Record<string, SpeedMetrics> | null = null;
Expand Down Expand Up @@ -172,6 +176,7 @@ async function renderMultipleLines(data: StatusJSON) {
speedMetrics,
windowedSpeedMetrics,
usageData,
proxyBudgetData,
sessionDuration,
skillsMetrics,
compactionData: hasCompactionWidget ? { count: compactionCount } : null,
Expand Down
2 changes: 2 additions & 0 deletions src/types/RenderContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
BlockMetrics,
SkillsMetrics
} from '../types';
import type { ProxyBudgetData } from '../utils/proxy-budget-fetch';

import type { SpeedMetrics } from './SpeedMetrics';
import type { StatusJSON } from './StatusJSON';
Expand Down Expand Up @@ -31,6 +32,7 @@ export interface RenderContext {
speedMetrics?: SpeedMetrics | null;
windowedSpeedMetrics?: Record<string, SpeedMetrics> | null;
usageData?: RenderUsageData | null;
proxyBudgetData?: ProxyBudgetData | null;
sessionDuration?: string | null;
blockMetrics?: BlockMetrics | null;
skillsMetrics?: SkillsMetrics | null;
Expand Down
258 changes: 258 additions & 0 deletions src/utils/__tests__/proxy-budget-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as https from 'https';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';

import {
PROXY_BUDGET_PRESETS,
fetchProxyBudget,
isProxyBudgetPreset,
type ProxyBudgetFetchOptions
} from '../proxy-budget-fetch';

const BASE_URL_ENV = 'TEST_PROXY_BASE_URL';
const TOKEN_ENV = 'TEST_PROXY_TOKEN';
const BASE_URL = 'https://proxy.example.com';
const TOKEN = 'test-token';

interface MockResponse {
status?: number;
body?: string;
error?: Error;
timeout?: boolean;
}

function mockHttpsRequest(response: MockResponse): {
spy: ReturnType<typeof vi.spyOn>;
lastHeaders: Record<string, string>;
} {
const captured: Record<string, string> = {};
const spy = vi.spyOn(https, 'request').mockImplementation(((opts: unknown, cb?: unknown) => {
const headers = (opts as { headers?: Record<string, string> }).headers ?? {};
Object.assign(captured, headers);
const req = new EventEmitter() as EventEmitter & {
end: () => void;
destroy: () => void;
setTimeout: (ms: number, fn: () => void) => void;
};
let timeoutFn: (() => void) | null = null;
req.setTimeout = (_ms, fn) => { timeoutFn = fn; };
req.destroy = () => { /* no-op */ };
req.end = () => {
setImmediate(() => {
if (response.timeout) {
if (timeoutFn)
timeoutFn();
return;
}
if (response.error) {
req.emit('error', response.error);
return;
}
const res = new EventEmitter() as EventEmitter & { statusCode: number };
res.statusCode = response.status ?? 200;
if (typeof cb === 'function') {
(cb as (r: typeof res) => void)(res);
}
setImmediate(() => {
res.emit('data', Buffer.from(response.body ?? ''));
res.emit('end');
});
});
};
return req;
}) as never);
return { spy, lastHeaders: captured };
}

function clearCache(): void {
const cacheFile = `${process.env.HOME}/.cache/ccstatusline/proxy-budget.json`;
const lockFile = `${process.env.HOME}/.cache/ccstatusline/proxy-budget.lock`;
try { fs.unlinkSync(cacheFile); } catch { /* ignore */ }
try { fs.unlinkSync(lockFile); } catch { /* ignore */ }
}

function defaultOpts(): ProxyBudgetFetchOptions {
return { baseUrlEnv: BASE_URL_ENV, tokenEnv: TOKEN_ENV };
}

describe('fetchProxyBudget', () => {
beforeEach(() => {
process.env[BASE_URL_ENV] = BASE_URL;
process.env[TOKEN_ENV] = TOKEN;
clearCache();
});

afterEach(() => {
Reflect.deleteProperty(process.env, BASE_URL_ENV);
Reflect.deleteProperty(process.env, TOKEN_ENV);
vi.restoreAllMocks();
clearCache();
});

it('resolves spend/budget/percentage from a LiteLLM-shaped /key/info response', async () => {
mockHttpsRequest({
body: JSON.stringify({
info: {
spend: 5,
max_budget: 50,
budget_reset_at: '2026-01-01T00:00:00Z'
}
})
});
const data = await fetchProxyBudget(defaultOpts());
expect(data).not.toBeNull();
expect(data?.spend).toBe(5);
expect(data?.budget).toBe(50);
expect(data?.percentage).toBeCloseTo(10);
expect(data?.resetAt).toBe('2026-01-01T00:00:00Z');
});

it('returns null when tokenEnv is unset, without calling https', async () => {
Reflect.deleteProperty(process.env, TOKEN_ENV);
const mock = mockHttpsRequest({ body: '{}' });
const data = await fetchProxyBudget(defaultOpts());
expect(data).toBeNull();
expect(mock.spy).not.toHaveBeenCalled();
});

it('supports custom dotted JSON paths (OpenRouter-style shape)', async () => {
mockHttpsRequest({ body: JSON.stringify({ data: { usage: 12, limit: 30 } }) });
const data = await fetchProxyBudget({
...defaultOpts(),
spendPath: 'data.usage',
budgetPath: 'data.limit'
});
expect(data?.spend).toBe(12);
expect(data?.budget).toBe(30);
expect(data?.percentage).toBeCloseTo(40);
});

it('returns null when a configured field is missing from the response', async () => {
mockHttpsRequest({ body: JSON.stringify({ info: { max_budget: 50 } }) });
const data = await fetchProxyBudget(defaultOpts());
expect(data).toBeNull();
});

it('returns null on non-2xx HTTP status', async () => {
mockHttpsRequest({ status: 503, body: '' });
const data = await fetchProxyBudget(defaultOpts());
expect(data).toBeNull();
});

it('returns null on malformed JSON body', async () => {
mockHttpsRequest({ body: 'not json' });
const data = await fetchProxyBudget(defaultOpts());
expect(data).toBeNull();
});

it('returns null when spend grossly exceeds budget (likely misconfigured spendPath)', async () => {
mockHttpsRequest({ body: JSON.stringify({ info: { spend: 99999, max_budget: 50 } }) });
const data = await fetchProxyBudget(defaultOpts());
expect(data).toBeNull();
});

it('returns null on request timeout', async () => {
mockHttpsRequest({ timeout: true });
const data = await fetchProxyBudget({ ...defaultOpts(), timeoutMs: 50 });
expect(data).toBeNull();
});

it('uses Authorization: Bearer header by default', async () => {
const { lastHeaders } = mockHttpsRequest({ body: JSON.stringify({ info: { spend: 1, max_budget: 10 } }) });
await fetchProxyBudget(defaultOpts());
expect(lastHeaders.Authorization).toBe(`Bearer ${TOKEN}`);
});

it('uses x-api-key header when authScheme is x-api-key', async () => {
const { lastHeaders } = mockHttpsRequest({ body: JSON.stringify({ info: { spend: 1, max_budget: 10 } }) });
await fetchProxyBudget({ ...defaultOpts(), authScheme: 'x-api-key' });
expect(lastHeaders['x-api-key']).toBe(TOKEN);
expect(lastHeaders.Authorization).toBeUndefined();
});

it('serves fresh cache on subsequent invocations within TTL without calling https again', async () => {
const mock = mockHttpsRequest({ body: JSON.stringify({ info: { spend: 7, max_budget: 50 } }) });
const first = await fetchProxyBudget({ ...defaultOpts(), cacheTtlSec: 60 });
const second = await fetchProxyBudget({ ...defaultOpts(), cacheTtlSec: 60 });
expect(first?.spend).toBe(7);
expect(second?.spend).toBe(7);
expect(mock.spy).toHaveBeenCalledTimes(1);
});

it('falls back to stale cache after a fetch failure when within stale window', async () => {
// Seed cache with a real response.
mockHttpsRequest({ body: JSON.stringify({ info: { spend: 3, max_budget: 50 } }) });
await fetchProxyBudget({ ...defaultOpts(), cacheTtlSec: 1 });

// Wait > ttl so cache is stale (but well within 10x).
await new Promise(resolve => setTimeout(resolve, 1100));
vi.restoreAllMocks();

// Now have the next request fail.
mockHttpsRequest({ status: 500, body: '' });
const data = await fetchProxyBudget({ ...defaultOpts(), cacheTtlSec: 1 });
expect(data?.spend).toBe(3);
});

it('returns null when neither network nor cache yield data', async () => {
mockHttpsRequest({ error: new Error('network down') });
const data = await fetchProxyBudget(defaultOpts());
expect(data).toBeNull();
});

it('preset=litellm uses /key/info with info.spend/info.max_budget paths', async () => {
mockHttpsRequest({ body: JSON.stringify({ info: { spend: 4, max_budget: 40, budget_reset_at: '2026-02-01T00:00:00Z' } }) });
const data = await fetchProxyBudget({ ...defaultOpts(), preset: 'litellm' });
expect(data?.spend).toBe(4);
expect(data?.budget).toBe(40);
expect(data?.percentage).toBeCloseTo(10);
expect(data?.resetAt).toBe('2026-02-01T00:00:00Z');
});

it('preset=openrouter resolves /api/v1/key with data.usage/data.limit/data.limit_reset', async () => {
mockHttpsRequest({ body: JSON.stringify({ data: { usage: 8, limit: 20, limit_reset: '2026-03-01T00:00:00Z' } }) });
const data = await fetchProxyBudget({ ...defaultOpts(), preset: 'openrouter' });
expect(data?.spend).toBe(8);
expect(data?.budget).toBe(20);
expect(data?.percentage).toBeCloseTo(40);
expect(data?.resetAt).toBe('2026-03-01T00:00:00Z');
});

it('user-supplied paths override preset defaults', async () => {
mockHttpsRequest({ body: JSON.stringify({ custom: { my_spend: 6, my_cap: 60 } }) });
const data = await fetchProxyBudget({
...defaultOpts(),
preset: 'openrouter',
spendPath: 'custom.my_spend',
budgetPath: 'custom.my_cap'
});
expect(data?.spend).toBe(6);
expect(data?.budget).toBe(60);
});

it('isProxyBudgetPreset accepts every registered preset key and rejects unknown ones', () => {
for (const key of Object.keys(PROXY_BUDGET_PRESETS)) {
expect(isProxyBudgetPreset(key)).toBe(true);
}
expect(isProxyBudgetPreset('bogus-proxy')).toBe(false);
expect(isProxyBudgetPreset('')).toBe(false);
});

it('every registered preset has a fully-specified shape', () => {
for (const [name, def] of Object.entries(PROXY_BUDGET_PRESETS)) {
expect(def.endpoint, name).toMatch(/\$\{baseUrl\}/);
expect(def.spendPath, name).toBeTruthy();
expect(def.budgetPath, name).toBeTruthy();
expect(def.resetAtPath, name).toBeTruthy();
expect(['bearer', 'x-api-key']).toContain(def.authScheme);
}
});
});
Loading