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
1 change: 1 addition & 0 deletions skills/monid/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ Each command supports `--help` for full usage. Here's what's available:
| `monid runs get` | Get run status and results (`-r <runId>`, `-w` to wait) |
| `monid runs stop` | Stop an in-progress run (`-r <runId>`). Not all runs can be stopped |
| `monid balance` | Show current workspace balance |
| `monid spend` | Summarize workspace spend by provider and endpoint |
| `monid setup` | Complete CLI setup after installation (no API key required) |
| `monid keys add` | Add an API key (`-k <key> -l <label>`) |
| `monid keys list` | Show configured keys |
Expand Down
32 changes: 32 additions & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,10 @@ export interface RunListItem {
providerResponse?: ProviderResponse;
price: Price;
cost?: Cost | null;
/** Result items returned by the run (drives PER_RESULT billing). */
resultCount?: number;
/** Units actually charged for the run. */
billedUnits?: number;
createdAt: string;
startedAt?: string;
completedAt?: string;
Expand All @@ -363,6 +367,34 @@ export interface RunsListResponse {
usage?: Usage;
}

// --- Spend ---

/** Aggregated run count and spend for one provider or endpoint. */
export interface SpendBucket {
provider: string;
providerName?: string;
/** Present on endpoint buckets only. */
endpoint?: string;
runs: number;
spend: number;
}

/** Workspace spend summary aggregated from the full runs list. */
export interface SpendReport {
runs: number;
spend: number;
currency: string;
firstRunAt?: string;
lastRunAt?: string;
providers: SpendBucket[];
topEndpoints: SpendBucket[];
topRuns: RunListItem[];
/** Runs that FAILED, timed out, or returned a non-2xx provider status. */
failedRuns: number;
/** Total billed across failed runs ($0 when billing is healthy). */
failedSpend: number;
}

// --- Balance ---

export interface BalanceResponse {
Expand Down
146 changes: 146 additions & 0 deletions src/commands/spend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { Command } from '@cliffy/command';
import { MonidAPI } from '../api/client.js';
import type { RunListItem, SpendBucket, SpendReport } from '../api/types.js';
import { ConfigManager } from '../config/manager.js';
import { handleError, MonidError } from '../utils/error.js';
import { printUpdateNotice, applyUpdateNote } from '../utils/update-check.js';
import { formatSpendReport } from '../output/format.js';
import { startSpinner, succeedSpinner, stopSpinner, updateSpinner } from '../output/spinner.js';

const PAGE_SIZE = 100;
const TOP_ENDPOINTS = 5;
const TOP_RUNS = 3;

export const spendCommand = new Command()
.name('spend')
.description('Summarize workspace spend by provider and endpoint.')
.option('-j, --json', 'Output as JSON.')
.action(async ({ json }) => {
try {
const config = new ConfigManager();
const active = config.getActiveKey();
if (!active) {
throw new MonidError(
'AUTH_FAILED',
'No active API key. Run "monid keys add" to configure one.',
);
}

const api = new MonidAPI({ apiKey: active.credential.key });

if (!json) {
startSpinner('Fetching runs...');
}

const items: RunListItem[] = [];
let cursor: string | undefined;
do {
const page = await api.listRuns(PAGE_SIZE, cursor);
items.push(...page.items);
cursor = page.cursor ?? undefined;
if (!json) updateSpinner(`Fetching runs... ${items.length}`);
} while (cursor);

const report = buildSpendReport(items);
const updateInfo = await config.getUpdateInfo();

if (json) {
const output = updateInfo ? applyUpdateNote(report, updateInfo) : report;
console.log(JSON.stringify(output, null, 2));
} else {
succeedSpinner(`Analyzed ${items.length} run(s)`);
formatSpendReport(report);
if (updateInfo) printUpdateNotice(updateInfo);
}
} catch (err) {
stopSpinner();
handleError(err, json);
}
});

/**
* Aggregate the full runs list into a spend report. Items are expected
* newest-first, as `/v1/runs` returns them.
*/
export function buildSpendReport(items: RunListItem[]): SpendReport {
const providers = new Map<string, SpendBucket>();
const endpoints = new Map<string, SpendBucket>();
let spend = 0;
let currency: string | undefined;
let failedRuns = 0;
let failedSpend = 0;

for (const run of items) {
const cost = run.cost?.value ?? 0;
spend += cost;
if (currency === undefined && run.cost) currency = run.cost.currency;

const providerEntry = upsertBucket(providers, run.provider, {
provider: run.provider,
providerName: run.providerName,
runs: 0,
spend: 0,
});
addTo(providerEntry, cost);

const endpointEntry = upsertBucket(endpoints, `${run.provider} ${run.endpoint}`, {
provider: run.provider,
providerName: run.providerName,
endpoint: run.endpoint,
runs: 0,
spend: 0,
});
addTo(endpointEntry, cost);

if (isFailedRun(run)) {
failedRuns += 1;
failedSpend += cost;
}
}

const topRuns = [...items]
.sort((a, b) => (b.cost?.value ?? 0) - (a.cost?.value ?? 0))
.slice(0, TOP_RUNS);

return {
runs: items.length,
spend,
currency: currency ?? 'USD',
providers: bySpend(providers),
topEndpoints: bySpend(endpoints).slice(0, TOP_ENDPOINTS),
topRuns,
failedRuns,
failedSpend,
firstRunAt: items[items.length - 1]?.createdAt,
lastRunAt: items[0]?.createdAt,
};
}

/** A run that failed outright: errored, timed out, or the provider itself
* returned a non-2xx status. Excludes BLOCKED runs — those are a budget
* guardrail firing as intended, not a failure. */
function isFailedRun(run: RunListItem): boolean {
if (run.status === 'FAILED' || run.status === 'TIME_OUT') return true;
const httpStatus = run.providerResponse?.httpStatus;
return httpStatus !== undefined && (httpStatus < 200 || httpStatus >= 300);
}

function upsertBucket(
map: Map<string, SpendBucket>,
key: string,
seed: SpendBucket,
): SpendBucket {
const existing = map.get(key);
if (existing) return existing;
map.set(key, seed);
return seed;
}

function addTo(entry: SpendBucket, cost: number): void {
entry.runs += 1;
entry.spend += cost;
}

function bySpend(map: Map<string, SpendBucket>): SpendBucket[] {
return [...map.values()].sort((a, b) => b.spend - a.spend);
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Command } from '@cliffy/command';
import { VERSION } from './config/constants.js';
import { balanceCommand } from './commands/balance.js';
import { spendCommand } from './commands/spend.js';
import { keysCommand } from './commands/keys/index.js';
import { discoverCommand } from './commands/discover.js';
import { inspectCommand } from './commands/inspect.js';
Expand All @@ -27,6 +28,7 @@ const cli = new Command()
.command('whoami', whoamiCommand)
.command('setup', setupCommand)
.command('balance', balanceCommand)
.command('spend', spendCommand)
.command('keys', keysCommand);

await cli.parse();
62 changes: 62 additions & 0 deletions src/output/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
RunError,
RunDetailResponse,
RunsListResponse,
SpendReport,
WhoamiResponse,
} from '../api/types.js';
import { resourceStateBadge } from './colors.js';
Expand Down Expand Up @@ -404,6 +405,67 @@ export function formatBalance(data: BalanceResponse): void {
console.log();
}

// --- Spend ---

export function formatSpendReport(report: SpendReport): void {
if (report.runs === 0) {
console.log(chalk.gray('No runs found.'));
return;
}

const range =
report.firstRunAt && report.lastRunAt
? chalk.gray(` · ${formatDate(report.firstRunAt)} — ${formatDate(report.lastRunAt)}`)
: '';
console.log();
console.log(
` Total spent: ${chalk.green(`$${report.spend.toFixed(2)}`)} ${report.currency}` + range,
);
console.log();

console.log(chalk.bold('By provider'));
renderTable(
['Provider', 'Runs', 'Spend'],
report.providers.map((b) => [
b.providerName || b.provider,
String(b.runs),
`$${b.spend.toFixed(4)}`,
]),
{ columns: { 1: { align: 'right' }, 2: { align: 'right' } } },
);

console.log();
console.log(chalk.bold('Top endpoints'));
renderTable(
['Provider', 'Endpoint', 'Runs', 'Spend'],
report.topEndpoints.map((b) => [
b.providerName || b.provider,
b.endpoint ?? '-',
String(b.runs),
`$${b.spend.toFixed(4)}`,
]),
{ columns: { 2: { align: 'right' }, 3: { align: 'right' } } },
);

console.log();
console.log(chalk.bold('Top runs'));
renderTable(
['Run ID', 'Endpoint', 'Results', 'Cost'],
report.topRuns.map((r) => [
chalk.bold(r.runId),
r.endpoint,
r.resultCount !== undefined ? String(r.resultCount) : '-',
r.cost ? `$${r.cost.value.toFixed(4)}` : '-',
]),
{ columns: { 2: { align: 'right' }, 3: { align: 'right' } } },
);

console.log();
const failedLine = `Failed runs: ${report.failedRuns} · billed $${report.failedSpend.toFixed(2)}`;
console.log(` ${report.failedSpend > 0 ? chalk.yellow(failedLine) : chalk.gray(failedLine)}`);
console.log();
}

// --- Whoami ---

export function formatWhoami(data: WhoamiResponse): void {
Expand Down
96 changes: 96 additions & 0 deletions test/commands/spend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, it, expect } from 'bun:test';
import { buildSpendReport } from '../../src/commands/spend.js';
import type { RunListItem } from '../../src/api/types.js';

function run(overrides: Partial<RunListItem>): RunListItem {
return {
runId: 'run_1',
caller: 'USER#u_1',
provider: 'tikhub',
providerName: 'TikHub',
endpoint: '/a',
status: 'COMPLETED',
providerResponse: { httpStatus: 200 },
price: { type: 'PER_CALL', amount: { value: 0.0015, currency: 'USD' } },
cost: { value: 0.0015, currency: 'USD' },
createdAt: '2026-08-24T00:00:00.000Z',
...overrides,
};
}

describe('buildSpendReport', () => {
it('totals spend and groups by provider and endpoint', () => {
const report = buildSpendReport([
run({ runId: 'run_1', endpoint: '/a', cost: { value: 0.001, currency: 'USD' } }),
run({ runId: 'run_2', endpoint: '/b', cost: { value: 0.002, currency: 'USD' } }),
run({
runId: 'run_3',
provider: 'apify',
providerName: 'Apify',
endpoint: '/c',
cost: { value: 0.5, currency: 'USD' },
}),
]);

expect(report.runs).toBe(3);
expect(report.spend).toBeCloseTo(0.503);
expect(report.providers.map((b) => b.provider)).toEqual(['apify', 'tikhub']);
expect(report.providers[0].spend).toBeCloseTo(0.5);
expect(report.providers[1].runs).toBe(2);
expect(report.topEndpoints[0]).toMatchObject({ provider: 'apify', endpoint: '/c' });
});

it('ranks top runs by cost', () => {
const report = buildSpendReport([
run({ runId: 'run_cheap', cost: { value: 0.001, currency: 'USD' } }),
run({ runId: 'run_big', cost: { value: 1, currency: 'USD' } }),
run({ runId: 'run_free', cost: null }),
]);

expect(report.topRuns[0].runId).toBe('run_big');
expect(report.topRuns).toHaveLength(3);
});

it('counts failed runs and their billed spend', () => {
const report = buildSpendReport([
run({}),
run({ runId: 'run_400', providerResponse: { httpStatus: 400 }, cost: null }),
run({ runId: 'run_timeout', status: 'TIME_OUT', cost: null }),
run({
runId: 'run_billed_fail',
providerResponse: { httpStatus: 500 },
cost: { value: 0.01, currency: 'USD' },
}),
]);

expect(report.failedRuns).toBe(3);
expect(report.failedSpend).toBeCloseTo(0.01);
});

it('does not count blocked runs as failures', () => {
const report = buildSpendReport([
run({ runId: 'run_blocked', status: 'BLOCKED', providerResponse: undefined, cost: null }),
]);

expect(report.failedRuns).toBe(0);
});

it('reads the date range from newest-first items', () => {
const report = buildSpendReport([
run({ runId: 'run_new', createdAt: '2026-08-24T00:00:00.000Z' }),
run({ runId: 'run_old', createdAt: '2026-07-21T00:00:00.000Z' }),
]);

expect(report.firstRunAt).toBe('2026-07-21T00:00:00.000Z');
expect(report.lastRunAt).toBe('2026-08-24T00:00:00.000Z');
});

it('returns an empty report for no runs', () => {
const report = buildSpendReport([]);

expect(report.runs).toBe(0);
expect(report.spend).toBe(0);
expect(report.providers).toEqual([]);
expect(report.firstRunAt).toBeUndefined();
});
});