Skip to content

src/agent-usage.ts

145 lines · 4.1 KB · TypeScript
Raw
import { cursorRequest } from './request.ts'
import { CursorApiError, trimString } from './lib/client.ts'

export type GetAgentUsageParams = {
	agentId: string
	runId?: string
}

export type AgentUsageTokens = {
	inputTokens?: number
	outputTokens?: number
	cacheReadTokens?: number
	cacheWriteTokens?: number
	totalTokens?: number
	[key: string]: unknown
}

export type AgentUsageCost = {
	rawCostCents: number
	chargedCents: number
	rawUsd: number
	chargedUsd: number
}

export type AgentUsageRun = {
	id?: string
	usage?: AgentUsageTokens
	cost?: AgentUsageCost
	[key: string]: unknown
}

export type AgentUsageResult = {
	agentId: string
	totalUsage?: AgentUsageTokens
	cost?: AgentUsageCost
	runs: AgentUsageRun[]
}

function asRecord(value: unknown): Record<string, unknown> | null {
	return value && typeof value === 'object' && !Array.isArray(value)
		? (value as Record<string, unknown>)
		: null
}

function asNumber(value: unknown): number | undefined {
	return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}

function centsToUsd(cents: number): number {
	return cents / 100
}

/**
 * Slim API `cost` (cents) into billed dollars. Returns undefined when the
 * billed record is missing — callers must not invent a dollar amount.
 */
function slimCost(value: unknown): AgentUsageCost | undefined {
	const record = asRecord(value)
	if (!record) return undefined
	const rawCostCents = asNumber(record.rawCostCents)
	const chargedCents = asNumber(record.chargedCents)
	if (rawCostCents === undefined && chargedCents === undefined) return undefined
	const raw = rawCostCents ?? 0
	const charged = chargedCents ?? 0
	return {
		rawCostCents: raw,
		chargedCents: charged,
		rawUsd: centsToUsd(raw),
		chargedUsd: centsToUsd(charged),
	}
}

function slimUsage(value: unknown): AgentUsageTokens | undefined {
	const record = asRecord(value)
	if (!record) return undefined
	return record as AgentUsageTokens
}

function slimRun(value: unknown): AgentUsageRun | null {
	const record = asRecord(value)
	if (!record) return null
	const run: AgentUsageRun = {}
	if (typeof record.id === 'string') run.id = record.id
	const usage = slimUsage(record.usage ?? record.totalUsage)
	if (usage) run.usage = usage
	const cost = slimCost(record.cost)
	if (cost) run.cost = cost
	return run
}

/**
 * Retrieve billed token usage for a Cursor Cloud Agent.
 *
 * Wraps `GET /v1/agents/{id}/usage` (optional `?runId=`). Dollars come from
 * the API `cost` record (`rawCostCents` / `chargedCents`) — never estimate
 * from token counts. `chargedCents === 0` means the tokens were plan-included.
 *
 * @param params.agentId - Cloud agent id (`bc-…`).
 * @param params.runId - Optional run id to scope usage.
 * @returns Slimmed usage: `{ agentId, totalUsage, cost?, runs }`.
 * @example
 * import { getAgentUsage } from 'kody:@kentcdodds/cursor/agent-usage'
 * const usage = await getAgentUsage({ agentId: 'bc-…' })
 * // => { agentId: 'bc-…', totalUsage: { totalTokens: 21456045 },
 * //      cost: { rawCostCents: 2500.9084, chargedCents: 0, rawUsd: 25.009084, chargedUsd: 0 },
 * //      runs: […] }
 *
 * @see https://cursor.com/docs/cloud-agent/api/endpoints
 */
export async function getAgentUsage(
	params: GetAgentUsageParams,
): Promise<AgentUsageResult> {
	const agentId = trimString(params.agentId)
	if (!agentId) throw new Error('agentId is required')

	const query: Record<string, string> = {}
	const runId = trimString(params.runId)
	if (runId) query.runId = runId

	const result = await cursorRequest({
		path: `/v1/agents/${encodeURIComponent(agentId)}/usage`,
		query,
	})
	if (result.status < 200 || result.status >= 300) {
		throw new CursorApiError(result.status, result.body)
	}

	const body = asRecord(result.body) ?? {}
	const runs = Array.isArray(body.runs)
		? body.runs.map(slimRun).filter((run): run is AgentUsageRun => run !== null)
		: []

	const slimmed: AgentUsageResult = {
		agentId,
		runs,
	}
	const totalUsage = slimUsage(body.totalUsage)
	if (totalUsage) slimmed.totalUsage = totalUsage
	const cost = slimCost(body.cost)
	if (cost) slimmed.cost = cost
	return slimmed
}

/** Default export: billed agent usage (tokens + API cost record). */
export default getAgentUsage