SPB Git

Repository Public

budget.ts 4.6 KB 70 lines typescript

1import { sql } from "./db";23export interface Policy {4  kill_switch: boolean; per_shot_usd: number; per_film_usd: number; per_day_usd: number; per_month_usd: number;5  max_regenerations: number; concurrency: number; auto_publish: boolean; scheduler_enabled: boolean; films_per_day: number;6  default_mode: "manual" | "assisted" | "autonomous"; qa_threshold: number; updated_at: Date;7}8export class BudgetError extends Error {9  constructor(readonly reason: "kill_switch" | "per_shot" | "per_film" | "per_day" | "per_month" | "unknown_price", message: string) {10    super(message);11    this.name = "BudgetError";12  }13}1415export async function getPolicy(): Promise<Policy> {16  const [p] = await sql<Policy[]>`SELECT * FROM budget_policy WHERE id = 1`;17  return p;18}1920export interface Spend { shot: number; film: number; day: number; month: number }21/** Money already spent plus money committed to jobs that are still running. */22export async function currentSpend(projectId: string | null, shotId: string | null): Promise<Spend> {23  const [r] = await sql<Spend[]>`24    WITH spent AS (25      SELECT usd, project_id, shot_id, at FROM cost_entries26      UNION ALL27      SELECT COALESCE(est_usd, 0), project_id, shot_id, created_at FROM provider_jobs WHERE status = 'submitted' AND started_at > now() - interval '2 hours'28    )29    SELECT30      COALESCE(SUM(usd) FILTER (WHERE ${shotId}::uuid IS NOT NULL AND shot_id = ${shotId}::uuid), 0)::float8 AS shot,31      COALESCE(SUM(usd) FILTER (WHERE ${projectId}::uuid IS NOT NULL AND project_id = ${projectId}::uuid), 0)::float8 AS film,32      COALESCE(SUM(usd) FILTER (WHERE at >= date_trunc('day', now())), 0)::float8 AS day,33      COALESCE(SUM(usd) FILTER (WHERE at >= date_trunc('month', now())), 0)::float8 AS month34    FROM spent`;35  return r;36}3738/** Pure decision, unit-tested: may `estimate` be spent given the policy and what is already committed? */39export function decide(policy: Pick<Policy, "kill_switch" | "per_shot_usd" | "per_film_usd" | "per_day_usd" | "per_month_usd">, spend: Spend, estimate: number | null, scope: { shot: boolean; film: boolean }): BudgetError | null {40  if (policy.kill_switch) return new BudgetError("kill_switch", "Kill switch is on: no new paid job may start");41  if (estimate === null) return new BudgetError("unknown_price", "The price of this call is unknown: a human must approve it explicitly");42  const over = (spent: number, cap: number) => spent + estimate > Number(cap) + 1e-9;43  const usd = (n: number) => `$${Number(n).toFixed(2)}`;44  if (scope.shot && over(spend.shot, policy.per_shot_usd)) return new BudgetError("per_shot", `Shot ceiling ${usd(policy.per_shot_usd)} would be exceeded (${usd(spend.shot)} spent + ${usd(estimate)})`);45  if (scope.film && over(spend.film, policy.per_film_usd)) return new BudgetError("per_film", `Film ceiling ${usd(policy.per_film_usd)} would be exceeded (${usd(spend.film)} spent + ${usd(estimate)})`);46  if (over(spend.day, policy.per_day_usd)) return new BudgetError("per_day", `Daily ceiling ${usd(policy.per_day_usd)} would be exceeded (${usd(spend.day)} spent + ${usd(estimate)})`);47  if (over(spend.month, policy.per_month_usd)) return new BudgetError("per_month", `Monthly ceiling ${usd(policy.per_month_usd)} would be exceeded (${usd(spend.month)} spent + ${usd(estimate)})`);48  return null;49}5051export async function assertCanSpend(o: { projectId: string | null; shotId: string | null; estimate: number | null; approvedUnknown?: boolean }) {52  const policy = await getPolicy();53  const spend = await currentSpend(o.projectId, o.shotId);54  const err = decide(policy, spend, o.estimate === null && o.approvedUnknown ? 0 : o.estimate, { shot: !!o.shotId, film: !!o.projectId });55  if (err) throw err;56}5758export async function recordCost(e: { jobId: string; projectId: string | null; shotId: string | null; kind: string; model: string; usd: number; source: string; failed?: boolean }) {59  await sql`INSERT INTO cost_entries (job_id, project_id, shot_id, kind, model, usd, source, failed)60            VALUES (${e.jobId}, ${e.projectId}, ${e.shotId}, ${e.kind}, ${e.model}, ${e.usd}, ${e.source}, ${e.failed ?? false})`;61}6263/** Observed mean USD per second for a video model at a resolution (used to refine estimates). */64export async function observedVideoRate(model: string, resolution: string): Promise<number | null> {65  const [r] = await sql<{ rate: number | null }[]>`66    SELECT (SUM(cost_usd) / NULLIF(SUM((settings->>'duration')::numeric), 0))::float8 AS rate67    FROM provider_jobs WHERE kind = 'video' AND status = 'done' AND model = ${model} AND settings->>'resolution' = ${resolution} AND cost_source = 'ticks'`;68  return r?.rate ?? null;69}70
Files main
Menu

Theme