forked from EduCraft/curriculum-project-hub
f3b087371a
Replace the single-scalar cost model on AgentRun with an append-only UsageFact ledger. One AgentRun owns zero or more UsageFact rows; each records one billable consumption event (model completion, external capability, or tool proxy) with its own provider/model/tokens/quantity/ cost. AgentRun.costUsd/inputTokens/outputTokens become a derived rollup cache. This unblocks external capabilities (PDF->MD bundle, audio/video->text) that bill in non-token units (pages, seconds) through a different provider than the main agent loop, without per-capability schema changes or nested AgentRuns (which would pollute lock/admission/session semantics). Contract: - spec/Spec/System/Agent/Usage.lean pins UsageFact, UsageFactKind, CostSource and three invariants: append-only; belongs to one run, never holds a lock; missing cost != zero (ADR-0022). - ADR-0026 records the decision, the rejected nested-Run alternative, the rollup cache strategy, and the deferred capability registry / pricebook / post-hoc correction flows. Schema: - UsageFact model with indexes on (runId, occurredAt), (runId, kind), (provider, model, occurredAt), (capabilityId, occurredAt). - Migration backfills one synthetic model_completion fact per existing run with recorded cost/tokens (correlationId = runId marks backfill); truly unrecorded runs stay runsWithoutCost per ADR-0022. Write path (trigger finish): - Write the UsageFact first, then mirror it onto AgentRun as two separate statements (not one transaction). The fact is the truth so it goes first; the cache is derived so it goes second. A crash between them leaves the cache stale but the usage service re-reads facts directly, so this is recoverable; the reverse order would lose the truth. Separate statements also avoid an AgentRun row lock held across the insert's FK ShareLock, which deadlocked concurrent workspace teardown under the Organization->Project->AgentRun->UsageFact cascade. Read paths: - org/usage.ts aggregates from UsageFact, ignoring the AgentRun cache. - slash /usage buckets by (fact.provider, fact.model); a run with a main loop + an external call lands in two buckets. - session detail exposes usageFacts[] + costSource for future per-run cost-breakdown UI. Tests: - usage.test.ts: 6 integration tests pin fact aggregation, missing-cost- !=-zero, multi-fact-per-run, empty-run, project-level, empty-org. - trigger.test.ts: existing /usage assertion ($0.0023, openrouter / mock-model) passes on the fact path. - feishu-reactions mock prisma gains usageFact.create.
222 lines
8.1 KiB
TypeScript
222 lines
8.1 KiB
TypeScript
import type { PrismaClient } from "@prisma/client";
|
|
import { decimalToNumberOrNull, formatInteger, formatUsd } from "../agent/cost.js";
|
|
import { sendText, type FeishuRuntime, type SendMessageOptions } from "./client.js";
|
|
|
|
export interface SlashInvocation {
|
|
readonly name: string;
|
|
readonly args: readonly string[];
|
|
}
|
|
|
|
export interface SlashCommandRunContext {
|
|
readonly invocation: SlashInvocation;
|
|
readonly projectId: string;
|
|
readonly chatId: string;
|
|
readonly rt: FeishuRuntime;
|
|
readonly sendOptions?: SendMessageOptions | undefined;
|
|
}
|
|
|
|
export interface SlashCommandDefinition {
|
|
readonly name: "help" | "project" | "usage";
|
|
readonly usage: string;
|
|
readonly summary: string;
|
|
readonly details: readonly string[];
|
|
run(context: SlashCommandRunContext): Promise<void>;
|
|
}
|
|
|
|
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;
|
|
const tokens = trimmed.split(/\s+/);
|
|
const rawCommand = tokens[0];
|
|
if (rawCommand === undefined || rawCommand.length <= 1) return null;
|
|
return { name: rawCommand.slice(1), args: tokens.slice(1) };
|
|
}
|
|
|
|
export function createSlashCommandRegistry(
|
|
deps: { readonly prisma: PrismaClient },
|
|
): ReadonlyMap<string, SlashCommandDefinition> {
|
|
const commands = new Map<string, SlashCommandDefinition>();
|
|
const add = (command: SlashCommandDefinition): void => {
|
|
if (commands.has(command.name)) throw new Error(`duplicate slash command definition: /${command.name}`);
|
|
commands.set(command.name, command);
|
|
};
|
|
|
|
add({
|
|
name: "help",
|
|
usage: "/help [project|usage]",
|
|
summary: "查看 Hub 命令。",
|
|
details: ["未知 slash 命令会明确报错,不会发送给 Agent。"],
|
|
run: async ({ invocation, chatId, rt, sendOptions }) => {
|
|
if (invocation.args.length > 1) {
|
|
await sendText(rt, chatId, "用法: /help [project|usage]", sendOptions);
|
|
return;
|
|
}
|
|
const target = invocation.args[0];
|
|
await sendText(rt, chatId, target === undefined
|
|
? formatSlashOverview(commands)
|
|
: formatSlashHelpTarget(target, commands), sendOptions);
|
|
},
|
|
});
|
|
|
|
add({
|
|
name: "project",
|
|
usage: "/project",
|
|
summary: "打开当前项目控制台,切换角色、管理会话和项目。",
|
|
details: ["不同区域按当前操作者的项目与组织权限显示。"],
|
|
run: async ({ chatId, rt, sendOptions }) => {
|
|
await sendText(rt, chatId, "请在绑定的项目群中使用 /project。", sendOptions);
|
|
},
|
|
});
|
|
|
|
add({
|
|
name: "usage",
|
|
usage: "/usage [current|project]",
|
|
summary: "查看 Hub 记录的真实 Agent 用量与成本。",
|
|
details: ["current 只统计当前角色的活跃会话;project 统计整个项目。"],
|
|
run: async ({ invocation, projectId, chatId, rt, sendOptions }) => {
|
|
const scope = invocation.args[0] ?? "current";
|
|
if (invocation.args.length > 1 || (scope !== "current" && scope !== "project")) {
|
|
await sendText(rt, chatId, "用法: /usage [current|project]", sendOptions);
|
|
return;
|
|
}
|
|
const sessionIds = scope === "project"
|
|
? undefined
|
|
: await currentRoleSessionIds(deps.prisma, projectId, chatId);
|
|
const runs = await deps.prisma.agentRun.findMany({
|
|
where: {
|
|
projectId,
|
|
...(sessionIds === undefined ? {} : { sessionId: { in: sessionIds } }),
|
|
status: { in: [...TERMINAL_RUN_STATUSES] },
|
|
finishedAt: { not: null },
|
|
},
|
|
orderBy: { finishedAt: "asc" },
|
|
select: {
|
|
usageFacts: {
|
|
select: {
|
|
provider: true,
|
|
model: true,
|
|
inputTokens: true,
|
|
outputTokens: true,
|
|
costUsd: true,
|
|
},
|
|
orderBy: { occurredAt: "asc" },
|
|
},
|
|
},
|
|
});
|
|
await sendText(rt, chatId, formatUsageReport(runs, scope), sendOptions);
|
|
},
|
|
});
|
|
|
|
return commands;
|
|
}
|
|
|
|
async function currentRoleSessionIds(
|
|
prisma: PrismaClient,
|
|
projectId: string,
|
|
chatId: string,
|
|
): Promise<string[]> {
|
|
const binding = await prisma.projectGroupBinding.findFirst({
|
|
where: { projectId, chatId, archivedAt: null },
|
|
select: { selectedRole: { select: { roleId: true } } },
|
|
});
|
|
if (binding === null) throw new Error("active project-group binding not found");
|
|
const sessions = await prisma.agentSession.findMany({
|
|
where: { projectId, roleId: binding.selectedRole.roleId, archivedAt: null },
|
|
select: { id: true },
|
|
});
|
|
return sessions.map((session) => session.id);
|
|
}
|
|
|
|
interface UsageRunWithFacts {
|
|
readonly usageFacts: readonly {
|
|
readonly provider: string;
|
|
readonly model: string | null;
|
|
readonly inputTokens: number | null;
|
|
readonly outputTokens: number | null;
|
|
readonly costUsd: unknown;
|
|
}[];
|
|
}
|
|
|
|
function formatUsageReport(runs: readonly UsageRunWithFacts[], scope: "current" | "project"): string {
|
|
if (runs.length === 0) return `${scope === "current" ? "当前角色会话" : "当前项目"}还没有已结束的 Agent run。`;
|
|
// Bucket by (fact.provider, fact.model) — ADR-0026: external capabilities
|
|
// carry their own provider/model and contribute their own meter, so a run
|
|
// with a main loop + an external call lands in two buckets. A run with no
|
|
// cost-bearing fact is "unrecorded" (ADR-0022: missing cost ≠ zero).
|
|
const buckets = new Map<string, {
|
|
provider: string; model: string; facts: number; inputTokens: number; outputTokens: number; costUsd: number;
|
|
}>();
|
|
let recordedRuns = 0;
|
|
let unrecordedRuns = 0;
|
|
let totalInputTokens = 0;
|
|
let totalOutputTokens = 0;
|
|
let totalCostUsd = 0;
|
|
for (const run of runs) {
|
|
const facts = run.usageFacts;
|
|
if (facts.length === 0 || !facts.some((f) => f.costUsd !== null && f.costUsd !== undefined)) {
|
|
unrecordedRuns++;
|
|
// Tokens from unrecorded runs still count toward totals (matches pre-0026).
|
|
for (const f of facts) {
|
|
totalInputTokens += f.inputTokens ?? 0;
|
|
totalOutputTokens += f.outputTokens ?? 0;
|
|
}
|
|
continue;
|
|
}
|
|
recordedRuns++;
|
|
for (const f of facts) {
|
|
const costUsd = decimalToNumberOrNull(f.costUsd);
|
|
if (costUsd === null) continue;
|
|
const model = f.model ?? "(unknown model)";
|
|
const key = `${f.provider}\u0000${model}`;
|
|
const bucket = buckets.get(key) ?? {
|
|
provider: f.provider, model, facts: 0, inputTokens: 0, outputTokens: 0, costUsd: 0,
|
|
};
|
|
bucket.facts += 1;
|
|
bucket.inputTokens += f.inputTokens ?? 0;
|
|
bucket.outputTokens += f.outputTokens ?? 0;
|
|
bucket.costUsd += costUsd;
|
|
buckets.set(key, bucket);
|
|
totalInputTokens += f.inputTokens ?? 0;
|
|
totalOutputTokens += f.outputTokens ?? 0;
|
|
totalCostUsd += costUsd;
|
|
}
|
|
}
|
|
const lines = [
|
|
`${scope === "current" ? "当前角色会话" : "当前项目"}用量`,
|
|
`已记录成本: ${formatInteger(recordedRuns)} runs · ${formatUsd(totalCostUsd)}`,
|
|
`Tokens: input ${formatInteger(totalInputTokens)} / output ${formatInteger(totalOutputTokens)}`,
|
|
];
|
|
if (unrecordedRuns > 0) lines.push(`另有 ${formatInteger(unrecordedRuns)} 个 run 未记录成本。`);
|
|
for (const bucket of buckets.values()) {
|
|
lines.push(`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.facts)} 次, ${formatUsd(bucket.costUsd)}`);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
export function formatSlashHelpTarget(
|
|
target: string,
|
|
commands: ReadonlyMap<string, SlashCommandDefinition>,
|
|
): string {
|
|
const normalized = target.trim().replace(/^\/+/, "");
|
|
const command = commands.get(normalized);
|
|
if (command === undefined) return [`未知 slash 命令 /${normalized}。`, "", formatSlashOverview(commands)].join("\n");
|
|
return [
|
|
`/${command.name}`,
|
|
command.summary,
|
|
"",
|
|
`用法: ${command.usage}`,
|
|
...command.details.map((detail) => `- ${detail}`),
|
|
].join("\n");
|
|
}
|
|
|
|
function formatSlashOverview(commands: ReadonlyMap<string, SlashCommandDefinition>): string {
|
|
return [
|
|
"可用 slash 命令:",
|
|
...[...commands.values()].map((command) => `/${command.name} - ${command.summary}`),
|
|
"",
|
|
"普通消息会使用 /project 中选定的当前角色。",
|
|
].join("\n");
|
|
}
|