From 45c4df7b8a237c3bb416acb4919bd50e952fc285 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Wed, 25 Feb 2026 21:35:44 -0600 Subject: [PATCH] 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 --- packages/adapter-utils/src/index.ts | 1 + packages/adapter-utils/src/types.ts | 3 ++ .../claude-local/src/server/execute.ts | 29 ++++++++++++-- .../codex-local/src/server/execute.ts | 29 ++++++++++++-- packages/shared/src/types/cost.ts | 4 ++ server/src/routes/agents.ts | 4 +- server/src/services/costs.ts | 38 +++++++++++++++++-- ui/src/api/heartbeats.ts | 9 +++-- 8 files changed, 104 insertions(+), 13 deletions(-) diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 22cab24b..cfc1bc8d 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -2,6 +2,7 @@ export type { AdapterAgent, AdapterRuntime, UsageSummary, + AdapterBillingType, AdapterExecutionResult, AdapterInvocationMeta, AdapterExecutionContext, diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 5bc8c7e5..65176754 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -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 | null; summary?: string | null; diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 4e115c51..0b1b7922 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -86,6 +86,16 @@ function buildLoginResult(input: { }; } +function hasNonEmptyEnvValue(env: Record, key: string): boolean { + const raw = env[key]; + return typeof raw === "string" && raw.trim().length > 0; +} + +function resolveClaudeBillingType(env: Record): "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 { const { runId, agent, config, context, authToken } = input; @@ -96,7 +106,15 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise => 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 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 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, key: string): boolean { + const raw = env[key]; + return typeof raw === "string" && raw.trim().length > 0; +} + +function resolveCodexBillingType(env: Record): "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 => 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 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 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[] = [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`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'api' then 1 else 0 end), 0)::int`, + subscriptionRunCount: + sql`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then 1 else 0 end), 0)::int`, + subscriptionInputTokens: + sql`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then coalesce((${heartbeatRuns.usageJson} ->> 'inputTokens')::int, 0) else 0 end), 0)::int`, + subscriptionOutputTokens: + sql`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`${issues.id}::text`; const runProjectLinks = db .selectDistinctOn([activityLog.runId, issues.projectId], { - runId: sql`${activityLog.runId}`, - projectId: sql`${issues.projectId}`, + runId: activityLog.runId, + projectId: issues.projectId, }) .from(activityLog) .innerJoin( diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index 50d26082..14c51017 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -22,9 +22,12 @@ export interface LiveRunForIssue { } export const heartbeatsApi = { - list: (companyId: string, agentId?: string) => { - const params = agentId ? `?agentId=${agentId}` : ""; - return api.get(`/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(`/companies/${companyId}/heartbeat-runs${qs ? `?${qs}` : ""}`); }, events: (runId: string, afterSeq = 0, limit = 200) => api.get(