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
6 changes: 6 additions & 0 deletions .changeset/cost-squad-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@bradygaster/squad-cli": patch
"@bradygaster/squad-sdk": patch
---

`squad cost` now finds the current session from the usage ledger instead of Scribe's session logs. The interactive shell stamps every usage record with its session id (the one `/resume` restores), as a new optional `squadSessionId` field, and plain `squad cost` reports the records that share the newest one. Before, the boundary was the newest `.squad/log/*.md` file, which the shell never writes: with no session log, plain `squad cost` reported every session, and a log written by another host during a shell run hid that run's earlier turns. The summary also counts Squad sessions instead of SDK sessions, so one shell run with three agents no longer shows as four sessions. Ledgers written before this change fall back to the old cutoff, and older CLIs still read the new records.
49 changes: 27 additions & 22 deletions docs/src/content/docs/features/cost-tracking.md
Original file line number Diff line number Diff line change
@@ -1,52 +1,57 @@
# Token Usage & Cost Tracking

Squad can track token usage and estimated cost for each agent spawn, roll that data up by session, and expose it through orchestration logs, terminal summaries, and telemetry backends.
Squad records token usage and estimated cost for each turn in the interactive shell, summarizes it by session with `squad cost`, and can export token metrics to telemetry backends.

---

## Overview

- Squad tracks token usage (input/output tokens) and estimated cost per agent spawn
- Usage data is recorded in orchestration logs and available via `squad cost` CLI
- The interactive shell (`squad` with no arguments) records input tokens, output tokens and estimated cost for every coordinator and agent turn
- `squad cost` reports that usage for the current session, for every session, or for one agent
- Optional budget limits can be configured per agent or per session

---

## How It Works

- The `CostTracker` class (`packages/squad-sdk/src/runtime/cost-tracker.ts`) accumulates token data
- Each orchestration log entry includes a **Token usage** row
- Each shell turn appends JSON Lines records to `.squad/log/usage.jsonl`: one when the turn starts and one with its usage when it completes
- Each record names the SDK session that ran the turn and the Squad session it belongs to. The Squad session is the shell session, the one `/resume` restores, so it covers the coordinator and every agent the shell talks to
- Hosts that run the coordinator themselves, such as Copilot CLI and VS Code, don't write this ledger
- The `CostTracker` class (`packages/squad-sdk/src/runtime/cost-tracker.ts`) accumulates token data for telemetry
- OTel metrics (`squad.tokens.input`, `squad.tokens.output`, `squad.tokens.cost`) are emitted when telemetry is enabled

The orchestration log template stores usage in a markdown table row like this:

```md
| **Token usage** | 12,450 in / 3,200 out — $0.0234 |
```
When the ledger has no data, `squad cost` falls back to older orchestration log entries that contain a **Token usage** row and labels the totals as estimates.

---

## Viewing Costs

```bash
squad cost # current session costs
squad cost --all # all historical costs
squad cost --agent fenster # costs for specific agent
squad cost --all # all recorded costs
squad cost --agent fenster # one agent's costs in the current session
```

The current session is the Squad session of the newest record in the ledger. If that record has no Squad session because an older Squad wrote it, `squad cost` counts the records after the newest timestamped session log in `.squad/log/` instead, or all records when there is none.

**Example output:**

```text
=== Squad Cost Summary ===
Total input tokens: 12,450
Total output tokens: 3,200
Estimated cost: $0.0234

--- By Agent ---
fenster: 12,450in / 3,200out ($0.0234) [1 turns, model: claude-sonnet-4.5]

--- By Session ---
session-abc123: 12,450in / 3,200out ($0.0234) [1 turns]
💰 Squad Cost Summary
━━━━━━━━━━━━━━━━━━━━

Agent Input Tokens Output Tokens Est. Cost
───────────── ────────────── ─────────────── ───────────
EECOM 7,000 100 $0.0700
Control 5,000 100 $0.0500
coordinator 1,000 100 $0.0100
───────────── ────────────── ─────────────── ───────────
Total 13,000 300 $0.1300

📊 3 agents across 3 turns in 1 session
Models: claude-sonnet-5 (3 turns, $0.1300)
Source: structured runtime usage ledger
Coverage: 3 of 3 turn attempts with recorded usage (100.0%)
```

---
Expand Down
54 changes: 42 additions & 12 deletions packages/squad-cli/src/cli/commands/cost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
resolveSquadState,
UsageLedger,
type StateBackend,
type UsageLedgerReadResult,
type UsageLedgerRecord,
type UsageLedgerTurnStart,
} from '@bradygaster/squad-sdk';
Expand All @@ -15,6 +16,7 @@ interface CostArgs {
interface CostEntry {
agent: string;
sessionId: string;
squadSessionId?: string;
turnId?: string;
model: string;
inputTokens: number;
Expand Down Expand Up @@ -97,6 +99,7 @@ function fromLedgerRecord(record: UsageLedgerRecord): CostEntry {
return {
agent: record.agentName ?? 'unknown',
sessionId: record.sessionId,
...(record.squadSessionId ? { squadSessionId: record.squadSessionId } : {}),
...(record.turnId ? { turnId: record.turnId } : {}),
model: record.model,
inputTokens: record.inputTokens,
Expand Down Expand Up @@ -213,7 +216,7 @@ function printSummary(
console.log(formatRow('Total', total.inputTokens, total.outputTokens, total.estimatedCost));
console.log();

const sessionCount = new Set(entries.map(entry => entry.sessionId)).size;
const sessionCount = new Set(entries.map(entry => entry.squadSessionId ?? entry.sessionId)).size;
const modelTotals = new Map<string, { turns: number; estimatedCost: number }>();
for (const entry of entries) {
const model = modelTotals.get(entry.model) ?? { turns: 0, estimatedCost: 0 };
Expand Down Expand Up @@ -261,14 +264,36 @@ function getCurrentSessionCutoff(backend: StateBackend): number | null {
}
}

/**
* The current session is the Squad session of the newest ledger row. When that
* row has no Squad session id (written by an older Squad), callers fall back to
* the newest session log in `log/`.
*/
function getCurrentSquadSessionId(ledger: UsageLedgerReadResult): string | undefined {
let newest: { timestamp: number; squadSessionId?: string } | undefined;
for (const row of [...ledger.records, ...ledger.turnStarts]) {
const timestamp = parseTimestamp(row.timestamp);
if (timestamp !== null && (!newest || timestamp >= newest.timestamp)) {
newest = { timestamp, squadSessionId: row.squadSessionId };
}
}
return newest?.squadSessionId;
}

interface StructuredScope {
cutoffTimestamp: number | null;
squadSessionId?: string;
}

function matchesScope(
record: Pick<UsageLedgerRecord | UsageLedgerTurnStart, 'agentName' | 'timestamp'>,
cutoffTimestamp: number | null,
record: Pick<UsageLedgerRecord | UsageLedgerTurnStart, 'agentName' | 'squadSessionId' | 'timestamp'>,
scope: StructuredScope,
agentFilter?: string,
): boolean {
const timestamp = parseTimestamp(record.timestamp);
return timestamp !== null
&& (cutoffTimestamp === null || timestamp > cutoffTimestamp)
&& (scope.squadSessionId === undefined || record.squadSessionId === scope.squadSessionId)
&& (scope.cutoffTimestamp === null || timestamp > scope.cutoffTimestamp)
&& (!agentFilter || (record.agentName ?? 'unknown').toLowerCase() === agentFilter.toLowerCase());
}

Expand All @@ -286,16 +311,15 @@ function calculateCoverage(entries: CostEntry[], turnStarts: UsageLedgerTurnStar
};
}

async function readStructuredEntries(
backend: StateBackend,
cutoffTimestamp: number | null,
function readStructuredEntries(
result: UsageLedgerReadResult,
scope: StructuredScope,
agentFilter?: string,
): Promise<{ entries: CostEntry[]; coverage: UsageCoverage; invalidRecordCount: number }> {
const result = await new UsageLedger(backend).read();
): { entries: CostEntry[]; coverage: UsageCoverage; invalidRecordCount: number } {
const entries = result.records
.filter(record => matchesScope(record, cutoffTimestamp, agentFilter))
.filter(record => matchesScope(record, scope, agentFilter))
.map(fromLedgerRecord);
const turnStarts = result.turnStarts.filter(record => matchesScope(record, cutoffTimestamp, agentFilter));
const turnStarts = result.turnStarts.filter(record => matchesScope(record, scope, agentFilter));

return {
entries,
Expand Down Expand Up @@ -344,8 +368,14 @@ export async function runCost(args: string[], teamRoot: string): Promise<void> {
return;
}

const ledger = await new UsageLedger(stateContext.backend).read();
const cutoffTimestamp = showAll ? null : getCurrentSessionCutoff(stateContext.backend);
const structured = await readStructuredEntries(stateContext.backend, cutoffTimestamp, agentFilter);
const squadSessionId = showAll ? undefined : getCurrentSquadSessionId(ledger);
const structured = readStructuredEntries(
ledger,
squadSessionId ? { cutoffTimestamp: null, squadSessionId } : { cutoffTimestamp },
agentFilter,
);
if (structured.entries.length > 0) {
printSummary(
structured.entries,
Expand Down
4 changes: 3 additions & 1 deletion packages/squad-cli/src/cli/shell/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ export async function runShell(): Promise<void> {
const nextUsageTurnId = (sessionId: string): string =>
`${sessionId}:${Date.now()}:${++usageTurnSequence}`;
streamingPipeline.onUsage(async (event) => {
await persistUsage(ledger => ledger.append(event));
await persistUsage(ledger => ledger.append(event, { squadSessionId: persistedSession.id }));
});
if (
rawContextWarningThreshold
Expand Down Expand Up @@ -522,6 +522,7 @@ export async function runShell(): Promise<void> {
await persistUsage(ledger => ledger.recordTurnStart({
turnId,
sessionId: sid,
squadSessionId: persistedSession.id,
agentName,
timestamp: new Date(),
}));
Expand Down Expand Up @@ -719,6 +720,7 @@ export async function runShell(): Promise<void> {
await persistUsage(ledger => ledger.recordTurnStart({
turnId,
sessionId: coordSid,
squadSessionId: persistedSession.id,
agentName: 'coordinator',
timestamp: new Date(),
}));
Expand Down
27 changes: 24 additions & 3 deletions packages/squad-sdk/src/runtime/usage-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface UsageLedgerRecord {
version: typeof USAGE_LEDGER_VERSION;
kind: 'usage';
sessionId: string;
squadSessionId?: string;
turnId?: string;
agentName?: string;
model: string;
Expand All @@ -30,17 +31,29 @@ export interface UsageLedgerTurnStart {
kind: 'turn-start';
turnId: string;
sessionId: string;
squadSessionId?: string;
agentName?: string;
timestamp: string;
}

export interface UsageTurnStart {
turnId: string;
sessionId: string;
squadSessionId?: string;
agentName?: string;
timestamp: Date;
}

/** Context stamped on a usage record that the runtime event does not carry. */
export interface UsageRecordContext {
/**
* The Squad session the record belongs to. One Squad session spans several SDK
* sessions (the coordinator's and one per agent), so `sessionId` cannot mark it.
* The interactive shell passes its persisted session id, the one `/resume` restores.
*/
squadSessionId?: string;
}

export interface UsageLedgerReadResult {
records: UsageLedgerRecord[];
turnStarts: UsageLedgerTurnStart[];
Expand All @@ -56,6 +69,10 @@ function isNonNegativeFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
}

function isOptionalNonEmptyString(value: unknown): boolean {
return value === undefined || (typeof value === 'string' && value.length > 0);
}

function isUsageLedgerRecord(value: unknown): value is UsageLedgerRecord {
if (!value || typeof value !== 'object') return false;

Expand All @@ -64,6 +81,7 @@ function isUsageLedgerRecord(value: unknown): value is UsageLedgerRecord {
&& record.kind === 'usage'
&& typeof record.sessionId === 'string'
&& record.sessionId.length > 0
&& isOptionalNonEmptyString(record.squadSessionId)
&& (record.turnId === undefined || (typeof record.turnId === 'string' && record.turnId.length > 0))
&& (record.agentName === undefined || typeof record.agentName === 'string')
&& typeof record.model === 'string'
Expand All @@ -87,12 +105,13 @@ function isUsageLedgerTurnStart(value: unknown): value is UsageLedgerTurnStart {
&& record.turnId.length > 0
&& typeof record.sessionId === 'string'
&& record.sessionId.length > 0
&& isOptionalNonEmptyString(record.squadSessionId)
&& (record.agentName === undefined || typeof record.agentName === 'string')
&& typeof record.timestamp === 'string'
&& Number.isFinite(Date.parse(record.timestamp));
}

function toRecord(event: UsageEvent): UsageLedgerRecord {
function toRecord(event: UsageEvent, context: UsageRecordContext = {}): UsageLedgerRecord {
const record: UsageLedgerRecord = {
version: USAGE_LEDGER_VERSION,
kind: 'usage',
Expand All @@ -102,6 +121,7 @@ function toRecord(event: UsageEvent): UsageLedgerRecord {
outputTokens: event.outputTokens,
estimatedCost: event.estimatedCost,
timestamp: event.timestamp.toISOString(),
...(context.squadSessionId !== undefined ? { squadSessionId: context.squadSessionId } : {}),
...(event.turnId ? { turnId: event.turnId } : {}),
...(event.agentName ? { agentName: event.agentName } : {}),
};
Expand All @@ -120,6 +140,7 @@ function toTurnStart(turn: UsageTurnStart): UsageLedgerTurnStart {
turnId: turn.turnId,
sessionId: turn.sessionId,
timestamp: turn.timestamp.toISOString(),
...(turn.squadSessionId !== undefined ? { squadSessionId: turn.squadSessionId } : {}),
...(turn.agentName ? { agentName: turn.agentName } : {}),
};

Expand All @@ -136,8 +157,8 @@ export class UsageLedger {
private readonly ledgerPath: string = USAGE_LEDGER_PATH,
) {}

async append(event: UsageEvent): Promise<void> {
await Promise.resolve(this.storage.append(this.ledgerPath, `${JSON.stringify(toRecord(event))}\n`));
async append(event: UsageEvent, context?: UsageRecordContext): Promise<void> {
await Promise.resolve(this.storage.append(this.ledgerPath, `${JSON.stringify(toRecord(event, context))}\n`));
}

async recordTurnStart(turn: UsageTurnStart): Promise<void> {
Expand Down
Loading
Loading