feat: add billing type tracking and cost enhancements

Add AdapterBillingType (api/subscription/unknown) to adapter execution results
so the system can distinguish API-billed vs subscription-billed runs. Enhance
cost service to aggregate subscription vs API run counts and token breakdowns.
Add limit param to heartbeat runs list API and client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-25 21:35:44 -06:00
parent 20a4ca08a5
commit 45c4df7b8a
8 changed files with 104 additions and 13 deletions

View File

@@ -2,6 +2,7 @@ export type {
AdapterAgent,
AdapterRuntime,
UsageSummary,
AdapterBillingType,
AdapterExecutionResult,
AdapterInvocationMeta,
AdapterExecutionContext,

View File

@@ -30,6 +30,8 @@ export interface UsageSummary {
cachedInputTokens?: number;
}
export type AdapterBillingType = "api" | "subscription" | "unknown";
export interface AdapterExecutionResult {
exitCode: number | null;
signal: string | null;
@@ -46,6 +48,7 @@ export interface AdapterExecutionResult {
sessionDisplayId?: string | null;
provider?: string | null;
model?: string | null;
billingType?: AdapterBillingType | null;
costUsd?: number | null;
resultJson?: Record<string, unknown> | null;
summary?: string | null;

View File

@@ -86,6 +86,16 @@ function buildLoginResult(input: {
};
}
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0;
}
function resolveClaudeBillingType(env: Record<string, string>): "api" | "subscription" {
// Claude uses API-key auth when ANTHROPIC_API_KEY is present; otherwise rely on local login/session auth.
return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") ? "api" : "subscription";
}
async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<ClaudeRuntimeConfig> {
const { runId, agent, config, context, authToken } = input;
@@ -96,7 +106,15 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
const cwd = workspaceCwd || asString(config.cwd, process.cwd());
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
? context.paperclipWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await ensureAbsoluteDirectory(cwd);
const envConfig = parseObject(config.env);
@@ -147,8 +165,8 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
if (linkedIssueIds.length > 0) {
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
}
if (workspaceCwd) {
env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd;
if (effectiveWorkspaceCwd) {
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
}
if (workspaceSource) {
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
@@ -162,6 +180,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
if (workspaceRepoRef) {
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
}
if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
}
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
@@ -263,6 +284,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
graceSec,
extraArgs,
} = runtimeConfig;
const billingType = resolveClaudeBillingType(env);
const skillsDir = await buildSkillsDir();
const runtimeSessionParams = parseObject(runtime.sessionParams);
@@ -434,6 +456,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
sessionDisplayId: resolvedSessionId,
provider: "anthropic",
model: parsedStream.model || asString(parsed.model, model),
billingType,
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),
resultJson: parsed,
summary: parsedStream.summary || asString(parsed.result, ""),

View File

@@ -50,6 +50,16 @@ function firstNonEmptyLine(text: string): string {
);
}
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0;
}
function resolveCodexBillingType(env: Record<string, string>): "api" | "subscription" {
// Codex uses API-key auth when OPENAI_API_KEY is present; otherwise rely on local login/session auth.
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
}
function codexHomeDir(): string {
const fromEnv = process.env.CODEX_HOME;
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
@@ -114,7 +124,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const workspaceId = asString(workspaceContext.workspaceId, "");
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
const cwd = workspaceCwd || asString(config.cwd, process.cwd());
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
? context.paperclipWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await ensureAbsoluteDirectory(cwd);
await ensureCodexSkillsInjected(onLog);
const envConfig = parseObject(config.env);
@@ -163,8 +181,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (linkedIssueIds.length > 0) {
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
}
if (workspaceCwd) {
env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd;
if (effectiveWorkspaceCwd) {
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
}
if (workspaceSource) {
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
@@ -178,12 +196,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (workspaceRepoRef) {
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
}
if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
}
for (const [k, v] of Object.entries(envConfig)) {
if (typeof v === "string") env[k] = v;
}
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const billingType = resolveCodexBillingType(env);
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
await ensureCommandResolvable(command, cwd, runtimeEnv);
@@ -320,6 +342,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
sessionDisplayId: resolvedSessionId,
provider: "openai",
model,
billingType,
costUsd: null,
resultJson: {
stdout: attempt.proc.stdout,

View File

@@ -29,4 +29,8 @@ export interface CostByAgent {
costCents: number;
inputTokens: number;
outputTokens: number;
apiRunCount: number;
subscriptionRunCount: number;
subscriptionInputTokens: number;
subscriptionOutputTokens: number;
}

View File

@@ -977,7 +977,9 @@ export function agentRoutes(db: Db) {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const agentId = req.query.agentId as string | undefined;
const runs = await heartbeat.list(companyId, agentId);
const limitParam = req.query.limit as string | undefined;
const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10) || 200)) : undefined;
const runs = await heartbeat.list(companyId, agentId, limit);
res.json(runs);
});

View File

@@ -105,7 +105,7 @@ export function costService(db: Db) {
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
return db
const costRows = await db
.select({
agentId: costEvents.agentId,
agentName: agents.name,
@@ -119,14 +119,46 @@ export function costService(db: Db) {
.where(and(...conditions))
.groupBy(costEvents.agentId, agents.name, agents.status)
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
const runConditions: ReturnType<typeof eq>[] = [eq(heartbeatRuns.companyId, companyId)];
if (range?.from) runConditions.push(gte(heartbeatRuns.finishedAt, range.from));
if (range?.to) runConditions.push(lte(heartbeatRuns.finishedAt, range.to));
const runRows = await db
.select({
agentId: heartbeatRuns.agentId,
apiRunCount:
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'api' then 1 else 0 end), 0)::int`,
subscriptionRunCount:
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then 1 else 0 end), 0)::int`,
subscriptionInputTokens:
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then coalesce((${heartbeatRuns.usageJson} ->> 'inputTokens')::int, 0) else 0 end), 0)::int`,
subscriptionOutputTokens:
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then coalesce((${heartbeatRuns.usageJson} ->> 'outputTokens')::int, 0) else 0 end), 0)::int`,
})
.from(heartbeatRuns)
.where(and(...runConditions))
.groupBy(heartbeatRuns.agentId);
const runRowsByAgent = new Map(runRows.map((row) => [row.agentId, row]));
return costRows.map((row) => {
const runRow = runRowsByAgent.get(row.agentId);
return {
...row,
apiRunCount: runRow?.apiRunCount ?? 0,
subscriptionRunCount: runRow?.subscriptionRunCount ?? 0,
subscriptionInputTokens: runRow?.subscriptionInputTokens ?? 0,
subscriptionOutputTokens: runRow?.subscriptionOutputTokens ?? 0,
};
});
},
byProject: async (companyId: string, range?: CostDateRange) => {
const issueIdAsText = sql<string>`${issues.id}::text`;
const runProjectLinks = db
.selectDistinctOn([activityLog.runId, issues.projectId], {
runId: sql<string>`${activityLog.runId}`,
projectId: sql<string>`${issues.projectId}`,
runId: activityLog.runId,
projectId: issues.projectId,
})
.from(activityLog)
.innerJoin(

View File

@@ -22,9 +22,12 @@ export interface LiveRunForIssue {
}
export const heartbeatsApi = {
list: (companyId: string, agentId?: string) => {
const params = agentId ? `?agentId=${agentId}` : "";
return api.get<HeartbeatRun[]>(`/companies/${companyId}/heartbeat-runs${params}`);
list: (companyId: string, agentId?: string, limit?: number) => {
const searchParams = new URLSearchParams();
if (agentId) searchParams.set("agentId", agentId);
if (limit) searchParams.set("limit", String(limit));
const qs = searchParams.toString();
return api.get<HeartbeatRun[]>(`/companies/${companyId}/heartbeat-runs${qs ? `?${qs}` : ""}`);
},
events: (runId: string, afterSeq = 0, limit = 200) =>
api.get<HeartbeatRunEvent[]>(