forked from bai/curriculum-project-hub
feat: add agent cost reporting
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { decimalToNumberOrNull, formatInteger, formatUsd } from "../agent/cost.js";
|
||||
import type { ModelRegistry, RoleEntry } from "../agent/models.js";
|
||||
import type { RuntimeSettings } from "../settings/runtime.js";
|
||||
import { sendText, type FeishuRuntime, type SendMessageOptions } from "./client.js";
|
||||
@@ -33,6 +34,8 @@ export interface SlashCommandRegistryDeps {
|
||||
readonly triggerQueue: TriggerQueue;
|
||||
}
|
||||
|
||||
const TERMINAL_RUN_STATUSES = ["COMPLETED", "FAILED", "TIMED_OUT", "CANCELED"] as const;
|
||||
|
||||
export function parseSlashInvocation(prompt: string): SlashInvocation | null {
|
||||
const trimmed = prompt.trim();
|
||||
if (!trimmed.startsWith("/")) return null;
|
||||
@@ -118,6 +121,51 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
|
||||
},
|
||||
});
|
||||
|
||||
add({
|
||||
name: "cost",
|
||||
usage: "/cost",
|
||||
summary: "查看当前会话已记录的 agent 成本。",
|
||||
details: [
|
||||
"只读取当前项目未归档 agent session 下已经结束的 run,不创建 agent run。",
|
||||
"只统计运行时真实记录到 AgentRun.costUsd 的成本;未记录成本的 run 会单独列出。",
|
||||
],
|
||||
run: async ({ invocation, projectId, chatId, rt, sendOptions }) => {
|
||||
if (invocation.args.length > 0) {
|
||||
await sendText(rt, chatId, ["用法错误: /cost 暂不接受参数。", "", formatBuiltinSlashCommandHelp(commands.get("cost")!)].join("\n"), sendOptions);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessions = await deps.prisma.agentSession.findMany({
|
||||
where: { projectId, archivedAt: null },
|
||||
orderBy: { updatedAt: "asc" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (sessions.length === 0) {
|
||||
await sendText(rt, chatId, "当前会话还没有 agent session。", sendOptions);
|
||||
return;
|
||||
}
|
||||
|
||||
const runs = await deps.prisma.agentRun.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
sessionId: { in: sessions.map((session) => session.id) },
|
||||
status: { in: [...TERMINAL_RUN_STATUSES] },
|
||||
finishedAt: { not: null },
|
||||
},
|
||||
orderBy: { finishedAt: "asc" },
|
||||
select: {
|
||||
model: true,
|
||||
provider: true,
|
||||
inputTokens: true,
|
||||
outputTokens: true,
|
||||
costUsd: true,
|
||||
},
|
||||
});
|
||||
|
||||
await sendText(rt, chatId, formatCostReport(runs), sendOptions);
|
||||
},
|
||||
});
|
||||
|
||||
add({
|
||||
name: "reset",
|
||||
usage: "/reset",
|
||||
@@ -142,6 +190,95 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
|
||||
return commands;
|
||||
}
|
||||
|
||||
interface CostReportRun {
|
||||
readonly model: string;
|
||||
readonly provider: string;
|
||||
readonly inputTokens: number | null;
|
||||
readonly outputTokens: number | null;
|
||||
readonly costUsd: unknown;
|
||||
}
|
||||
|
||||
interface CostReportBucket {
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
runs: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
costUsd: number;
|
||||
}
|
||||
|
||||
function formatCostReport(runs: readonly CostReportRun[]): string {
|
||||
if (runs.length === 0) {
|
||||
return "当前会话还没有已结束的 agent run。";
|
||||
}
|
||||
|
||||
const buckets = new Map<string, CostReportBucket>();
|
||||
let recordedRuns = 0;
|
||||
let unrecordedRuns = 0;
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
let totalCostUsd = 0;
|
||||
|
||||
for (const run of runs) {
|
||||
const costUsd = decimalToNumberOrNull(run.costUsd);
|
||||
if (costUsd === null) {
|
||||
unrecordedRuns++;
|
||||
continue;
|
||||
}
|
||||
|
||||
recordedRuns++;
|
||||
const inputTokens = run.inputTokens ?? 0;
|
||||
const outputTokens = run.outputTokens ?? 0;
|
||||
totalInputTokens += inputTokens;
|
||||
totalOutputTokens += outputTokens;
|
||||
totalCostUsd += costUsd;
|
||||
|
||||
const key = `${run.provider}\u0000${run.model}`;
|
||||
let bucket = buckets.get(key);
|
||||
if (bucket === undefined) {
|
||||
bucket = {
|
||||
provider: run.provider,
|
||||
model: run.model,
|
||||
runs: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
costUsd: 0,
|
||||
};
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
bucket.runs++;
|
||||
bucket.inputTokens += inputTokens;
|
||||
bucket.outputTokens += outputTokens;
|
||||
bucket.costUsd += costUsd;
|
||||
}
|
||||
|
||||
if (recordedRuns === 0) {
|
||||
return [
|
||||
"当前会话已有已结束 agent run,但还没有任何 run 记录到真实成本。",
|
||||
`未记录成本: ${formatInteger(unrecordedRuns)} runs。`,
|
||||
"后续 run 需要 SDK 返回 total_cost_usd 才会进入 /cost 合计。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"当前会话已记录 agent 成本:",
|
||||
`总计: ${formatUsd(totalCostUsd)}`,
|
||||
`Runs: ${formatInteger(recordedRuns)} 已记录${unrecordedRuns > 0 ? ` / ${formatInteger(unrecordedRuns)} 未记录` : ""}`,
|
||||
`Tokens: input ${formatInteger(totalInputTokens)} / output ${formatInteger(totalOutputTokens)}`,
|
||||
"",
|
||||
"按模型:",
|
||||
];
|
||||
|
||||
const sortedBuckets = [...buckets.values()].sort((a, b) => b.costUsd - a.costUsd);
|
||||
for (const bucket of sortedBuckets) {
|
||||
lines.push(
|
||||
`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.runs)} runs, ${formatUsd(bucket.costUsd)}, input ${formatInteger(bucket.inputTokens)} / output ${formatInteger(bucket.outputTokens)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatHelpCommandInvocation(
|
||||
invocation: SlashInvocation,
|
||||
registry: ModelRegistry,
|
||||
|
||||
Reference in New Issue
Block a user