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:
@@ -2,6 +2,7 @@ export type {
|
|||||||
AdapterAgent,
|
AdapterAgent,
|
||||||
AdapterRuntime,
|
AdapterRuntime,
|
||||||
UsageSummary,
|
UsageSummary,
|
||||||
|
AdapterBillingType,
|
||||||
AdapterExecutionResult,
|
AdapterExecutionResult,
|
||||||
AdapterInvocationMeta,
|
AdapterInvocationMeta,
|
||||||
AdapterExecutionContext,
|
AdapterExecutionContext,
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export interface UsageSummary {
|
|||||||
cachedInputTokens?: number;
|
cachedInputTokens?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdapterBillingType = "api" | "subscription" | "unknown";
|
||||||
|
|
||||||
export interface AdapterExecutionResult {
|
export interface AdapterExecutionResult {
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
signal: string | null;
|
signal: string | null;
|
||||||
@@ -46,6 +48,7 @@ export interface AdapterExecutionResult {
|
|||||||
sessionDisplayId?: string | null;
|
sessionDisplayId?: string | null;
|
||||||
provider?: string | null;
|
provider?: string | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
|
billingType?: AdapterBillingType | null;
|
||||||
costUsd?: number | null;
|
costUsd?: number | null;
|
||||||
resultJson?: Record<string, unknown> | null;
|
resultJson?: Record<string, unknown> | null;
|
||||||
summary?: string | null;
|
summary?: string | null;
|
||||||
|
|||||||
@@ -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> {
|
async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<ClaudeRuntimeConfig> {
|
||||||
const { runId, agent, config, context, authToken } = input;
|
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 workspaceId = asString(workspaceContext.workspaceId, "") || null;
|
||||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
|
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
|
||||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || 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);
|
await ensureAbsoluteDirectory(cwd);
|
||||||
|
|
||||||
const envConfig = parseObject(config.env);
|
const envConfig = parseObject(config.env);
|
||||||
@@ -147,8 +165,8 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
|||||||
if (linkedIssueIds.length > 0) {
|
if (linkedIssueIds.length > 0) {
|
||||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||||
}
|
}
|
||||||
if (workspaceCwd) {
|
if (effectiveWorkspaceCwd) {
|
||||||
env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd;
|
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||||
}
|
}
|
||||||
if (workspaceSource) {
|
if (workspaceSource) {
|
||||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||||
@@ -162,6 +180,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
|||||||
if (workspaceRepoRef) {
|
if (workspaceRepoRef) {
|
||||||
env.PAPERCLIP_WORKSPACE_REPO_REF = 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)) {
|
for (const [key, value] of Object.entries(envConfig)) {
|
||||||
if (typeof value === "string") env[key] = value;
|
if (typeof value === "string") env[key] = value;
|
||||||
@@ -263,6 +284,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
graceSec,
|
graceSec,
|
||||||
extraArgs,
|
extraArgs,
|
||||||
} = runtimeConfig;
|
} = runtimeConfig;
|
||||||
|
const billingType = resolveClaudeBillingType(env);
|
||||||
const skillsDir = await buildSkillsDir();
|
const skillsDir = await buildSkillsDir();
|
||||||
|
|
||||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||||
@@ -434,6 +456,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
sessionDisplayId: resolvedSessionId,
|
sessionDisplayId: resolvedSessionId,
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
model: parsedStream.model || asString(parsed.model, model),
|
model: parsedStream.model || asString(parsed.model, model),
|
||||||
|
billingType,
|
||||||
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),
|
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),
|
||||||
resultJson: parsed,
|
resultJson: parsed,
|
||||||
summary: parsedStream.summary || asString(parsed.result, ""),
|
summary: parsedStream.summary || asString(parsed.result, ""),
|
||||||
|
|||||||
@@ -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 {
|
function codexHomeDir(): string {
|
||||||
const fromEnv = process.env.CODEX_HOME;
|
const fromEnv = process.env.CODEX_HOME;
|
||||||
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
|
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 workspaceId = asString(workspaceContext.workspaceId, "");
|
||||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
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 ensureAbsoluteDirectory(cwd);
|
||||||
await ensureCodexSkillsInjected(onLog);
|
await ensureCodexSkillsInjected(onLog);
|
||||||
const envConfig = parseObject(config.env);
|
const envConfig = parseObject(config.env);
|
||||||
@@ -163,8 +181,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
if (linkedIssueIds.length > 0) {
|
if (linkedIssueIds.length > 0) {
|
||||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||||
}
|
}
|
||||||
if (workspaceCwd) {
|
if (effectiveWorkspaceCwd) {
|
||||||
env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd;
|
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||||
}
|
}
|
||||||
if (workspaceSource) {
|
if (workspaceSource) {
|
||||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||||
@@ -178,12 +196,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
if (workspaceRepoRef) {
|
if (workspaceRepoRef) {
|
||||||
env.PAPERCLIP_WORKSPACE_REPO_REF = 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)) {
|
for (const [k, v] of Object.entries(envConfig)) {
|
||||||
if (typeof v === "string") env[k] = v;
|
if (typeof v === "string") env[k] = v;
|
||||||
}
|
}
|
||||||
if (!hasExplicitApiKey && authToken) {
|
if (!hasExplicitApiKey && authToken) {
|
||||||
env.PAPERCLIP_API_KEY = authToken;
|
env.PAPERCLIP_API_KEY = authToken;
|
||||||
}
|
}
|
||||||
|
const billingType = resolveCodexBillingType(env);
|
||||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||||
|
|
||||||
@@ -320,6 +342,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
sessionDisplayId: resolvedSessionId,
|
sessionDisplayId: resolvedSessionId,
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
model,
|
model,
|
||||||
|
billingType,
|
||||||
costUsd: null,
|
costUsd: null,
|
||||||
resultJson: {
|
resultJson: {
|
||||||
stdout: attempt.proc.stdout,
|
stdout: attempt.proc.stdout,
|
||||||
|
|||||||
@@ -29,4 +29,8 @@ export interface CostByAgent {
|
|||||||
costCents: number;
|
costCents: number;
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
|
apiRunCount: number;
|
||||||
|
subscriptionRunCount: number;
|
||||||
|
subscriptionInputTokens: number;
|
||||||
|
subscriptionOutputTokens: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -977,7 +977,9 @@ export function agentRoutes(db: Db) {
|
|||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
const agentId = req.query.agentId as string | undefined;
|
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);
|
res.json(runs);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function costService(db: Db) {
|
|||||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||||
|
|
||||||
return db
|
const costRows = await db
|
||||||
.select({
|
.select({
|
||||||
agentId: costEvents.agentId,
|
agentId: costEvents.agentId,
|
||||||
agentName: agents.name,
|
agentName: agents.name,
|
||||||
@@ -119,14 +119,46 @@ export function costService(db: Db) {
|
|||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.groupBy(costEvents.agentId, agents.name, agents.status)
|
.groupBy(costEvents.agentId, agents.name, agents.status)
|
||||||
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
|
.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) => {
|
byProject: async (companyId: string, range?: CostDateRange) => {
|
||||||
const issueIdAsText = sql<string>`${issues.id}::text`;
|
const issueIdAsText = sql<string>`${issues.id}::text`;
|
||||||
const runProjectLinks = db
|
const runProjectLinks = db
|
||||||
.selectDistinctOn([activityLog.runId, issues.projectId], {
|
.selectDistinctOn([activityLog.runId, issues.projectId], {
|
||||||
runId: sql<string>`${activityLog.runId}`,
|
runId: activityLog.runId,
|
||||||
projectId: sql<string>`${issues.projectId}`,
|
projectId: issues.projectId,
|
||||||
})
|
})
|
||||||
.from(activityLog)
|
.from(activityLog)
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ export interface LiveRunForIssue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const heartbeatsApi = {
|
export const heartbeatsApi = {
|
||||||
list: (companyId: string, agentId?: string) => {
|
list: (companyId: string, agentId?: string, limit?: number) => {
|
||||||
const params = agentId ? `?agentId=${agentId}` : "";
|
const searchParams = new URLSearchParams();
|
||||||
return api.get<HeartbeatRun[]>(`/companies/${companyId}/heartbeat-runs${params}`);
|
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) =>
|
events: (runId: string, afterSeq = 0, limit = 200) =>
|
||||||
api.get<HeartbeatRunEvent[]>(
|
api.get<HeartbeatRunEvent[]>(
|
||||||
|
|||||||
Reference in New Issue
Block a user