forked from EduCraft/curriculum-project-hub
feat: add agent cost reporting
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
export function formatRunCostLine(costUsd: number | undefined): string {
|
||||
return costUsd === undefined
|
||||
? "本次成本: 未记录"
|
||||
: `本次成本: ${formatUsd(costUsd)}`;
|
||||
}
|
||||
|
||||
export function formatInteger(value: number): string {
|
||||
return new Intl.NumberFormat("en-US").format(value);
|
||||
}
|
||||
|
||||
export function formatUsd(value: number): string {
|
||||
const digits = value > 0 && value < 0.01 ? 4 : 2;
|
||||
return `$${value.toFixed(digits)}`;
|
||||
}
|
||||
|
||||
export function decimalToNumberOrNull(value: unknown): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const numberValue = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(numberValue)) {
|
||||
throw new Error("invalid cost value");
|
||||
}
|
||||
return numberValue;
|
||||
}
|
||||
@@ -73,6 +73,8 @@ export interface RunResult {
|
||||
readonly status: RunStatus;
|
||||
readonly text: string;
|
||||
readonly usage: { inputTokens: number; outputTokens: number };
|
||||
/** Provider- or gateway-reported cost for this run, in USD. */
|
||||
readonly costUsd?: number | undefined;
|
||||
readonly numTurns: number;
|
||||
readonly sdkSessionId?: string | undefined;
|
||||
readonly error?: string;
|
||||
@@ -123,6 +125,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
|
||||
let fullText = "";
|
||||
let usage = { inputTokens: 0, outputTokens: 0 };
|
||||
let costUsd: number | undefined;
|
||||
let numTurns = 0;
|
||||
let sdkSessionId: string | undefined;
|
||||
let error: string | undefined;
|
||||
@@ -248,6 +251,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
case "result": {
|
||||
const result = message as SDKResultMessage;
|
||||
sdkSessionId = result.session_id;
|
||||
costUsd = Number.isFinite(result.total_cost_usd) ? result.total_cost_usd : undefined;
|
||||
if (result.subtype !== "success") {
|
||||
error = `result_${result.subtype}`;
|
||||
}
|
||||
@@ -261,6 +265,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
status: error !== undefined ? "failed" : "completed",
|
||||
text: fullText,
|
||||
usage,
|
||||
...(costUsd !== undefined ? { costUsd } : {}),
|
||||
numTurns,
|
||||
sdkSessionId,
|
||||
...(error !== undefined ? { error } : {}),
|
||||
@@ -271,6 +276,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
status: aborted ? "interrupted" : "failed",
|
||||
text: fullText,
|
||||
usage,
|
||||
...(costUsd !== undefined ? { costUsd } : {}),
|
||||
numTurns,
|
||||
sdkSessionId,
|
||||
...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }),
|
||||
|
||||
@@ -106,20 +106,23 @@ export class StreamingAgentCard {
|
||||
recordToolUseEnd({ runId: this.runId, ...params });
|
||||
this.scheduleFlush();
|
||||
}
|
||||
async finish(fallbackText: string, options: { readonly interrupted?: boolean } = {}): Promise<void> {
|
||||
async finish(fallbackText: string, options: { readonly interrupted?: boolean; readonly footerText?: string | undefined } = {}): Promise<void> {
|
||||
await this.flushChain;
|
||||
this.interrupted = options.interrupted === true;
|
||||
const footerText = options.footerText ?? "";
|
||||
const fallbackWithFooter = appendFooter(fallbackText, footerText);
|
||||
try {
|
||||
let updated = true;
|
||||
if (this.text.length > 0) {
|
||||
this.text = appendFooter(this.text, footerText);
|
||||
updated = await this.flushCard("complete", this.text);
|
||||
} else if (this.currentMessageId === null && fallbackText.length > 0) {
|
||||
} else if (this.currentMessageId === null && fallbackWithFooter.length > 0) {
|
||||
// No streaming text was sent. If we never created a card, send one now.
|
||||
this.text = fallbackText;
|
||||
this.text = fallbackWithFooter;
|
||||
updated = await this.flushCard("complete", this.text);
|
||||
} else if (this.currentMessageId !== null) {
|
||||
// Patch the existing card with the final text.
|
||||
updated = await this.flushCard("complete", fallbackText);
|
||||
updated = await this.flushCard("complete", fallbackWithFooter);
|
||||
}
|
||||
if (!updated && this.interrupted) {
|
||||
await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions);
|
||||
@@ -228,3 +231,9 @@ export class StreamingAgentCard {
|
||||
return "thinking";
|
||||
}
|
||||
}
|
||||
|
||||
function appendFooter(text: string, footerText: string): string {
|
||||
if (footerText === "") return text;
|
||||
if (text === "") return footerText;
|
||||
return `${text.trimEnd()}\n\n${footerText}`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -32,6 +32,7 @@ import type { RuntimeSettings } from "../settings/runtime.js";
|
||||
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
||||
import { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js";
|
||||
import { writeAudit } from "../audit.js";
|
||||
import { formatRunCostLine } from "../agent/cost.js";
|
||||
import { StreamingAgentCard } from "./card/streaming-card.js";
|
||||
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
|
||||
import { readFeishuContext } from "./read.js";
|
||||
@@ -364,7 +365,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
: result.status === "failed" && result.error !== undefined
|
||||
? `\u5904\u7406\u5931\u8D25: ${result.error}`
|
||||
: result.text;
|
||||
await card.finish(finalText, { interrupted });
|
||||
await card.finish(finalText, { interrupted, footerText: formatRunCostLine(result.costUsd) });
|
||||
const metadataPatch = sessionMetadataPatch(result.sdkSessionId);
|
||||
if (metadataPatch !== null) {
|
||||
await deps.prisma.agentSession.update({
|
||||
@@ -378,6 +379,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
status: interrupted ? "CANCELED" : result.status === "completed" ? "COMPLETED" : result.status === "length" ? "TIMED_OUT" : "FAILED",
|
||||
inputTokens: result.usage.inputTokens,
|
||||
outputTokens: result.usage.outputTokens,
|
||||
...(result.costUsd !== undefined ? { costUsd: result.costUsd, costSource: "provider_reported" } : {}),
|
||||
error: result.error ?? null,
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user