Luigit
repositories / will

will

owned by admin

src/service/usage.ts

Raw
/**
 * Cost view math (read-only surface): prices come from the pi model catalog
 * (zai coding endpoint) and every function here is pure so the projection is
 * unit-testable without a database or a live clock.
 */

export interface TurnUsageRow {
  ts: string
  payload: unknown
}

export interface DayUsage {
  date: string
  input: number
  output: number
  cacheRead: number
  cacheWrite: number
  responses: number
  /** USD; null when any contributing row used a model without a price entry */
  costUsd: number | null
}

export interface UsageProjection {
  todayUsd: number | null
  mtdUsd: number | null
  projectedEomUsd: number | null
}

export interface UsageReport {
  days: DayUsage[]
  totals: Omit<DayUsage, 'date'>
  projection: UsageProjection
}

/** USD per MTok (input, output, cacheRead); cacheWrite unpriced (unbilled). */
const PRICES_PER_MTOK: Record<string, { input: number; output: number; cacheRead: number }> = {
  'zai/glm-5.3-flash': { input: 0.15, output: 0.5, cacheRead: 0.03 },
}

interface UsagePayload {
  provider?: unknown
  model?: unknown
  input?: unknown
  output?: unknown
  cacheRead?: unknown
  cacheWrite?: unknown
  responses?: unknown
}

const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)

function costOfRow(payload: unknown): number | null {
  const p = payload as UsagePayload
  const provider = typeof p?.provider === 'string' ? p.provider : 'unknown'
  const model = typeof p?.model === 'string' ? p.model : 'unknown'
  const price = PRICES_PER_MTOK[`${provider}/${model}`]
  if (price === undefined) return null
  return (
    (num(p?.input) * price.input +
      num(p?.output) * price.output +
      num(p?.cacheRead) * price.cacheRead) /
    1e6
  )
}

const dayKey = (ts: string): string => ts.slice(0, 10)

const HOUR_MS = 3_600_000
/** Floor on elapsed-day fraction so a just-past-midnight spike cannot explode the rate */
const MIN_ELAPSED_FRACTION = 0.5 / 24

export function projectUsage(days: DayUsage[], now: Date): UsageProjection {
  const todayKey = now.toISOString().slice(0, 10)
  const today = days.find((d) => d.date === todayKey)
  if (today === undefined || today.costUsd === null) {
    return { todayUsd: null, mtdUsd: null, projectedEomUsd: null }
  }
  const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
  const elapsed = Math.max((now.getTime() - midnight) / (24 * HOUR_MS), MIN_ELAPSED_FRACTION)
  const todayRate = today.costUsd / elapsed
  const month = todayKey.slice(0, 7)
  const mtd = days
    .filter((d) => d.date.slice(0, 7) === month && d.costUsd !== null)
    .reduce((sum, d) => sum + (d.costUsd ?? 0), 0)
  const monthEnd = Date.UTC(
    now.getUTCFullYear(),
    now.getUTCMonth() + 1,
    1, // first day of next month == end of this one
  )
  const projectedEom = mtd + todayRate * ((monthEnd - now.getTime()) / (24 * HOUR_MS))
  return { todayUsd: todayRate, mtdUsd: mtd, projectedEomUsd: projectedEom }
}

export function aggregateTurnUsage(rows: TurnUsageRow[], now: Date): UsageReport {
  interface Acc extends Omit<DayUsage, 'costUsd'> {
    priced: boolean
    costUsd: number
  }
  const byDay = new Map<string, Acc>()
  for (const row of rows) {
    const p = row.payload as UsagePayload
    const date = dayKey(typeof row.ts === 'string' ? row.ts : '')
    const day = byDay.get(date) ?? {
      date,
      input: 0,
      output: 0,
      cacheRead: 0,
      cacheWrite: 0,
      responses: 0,
      costUsd: 0,
      priced: true,
    }
    day.input += num(p?.input)
    day.output += num(p?.output)
    day.cacheRead += num(p?.cacheRead)
    day.cacheWrite += num(p?.cacheWrite)
    day.responses += num(p?.responses)
    const cost = costOfRow(row.payload)
    if (cost === null) day.priced = false
    else day.costUsd += cost
    byDay.set(date, day)
  }
  const days = [...byDay.values()]
    .map(({ priced, ...d }) => ({ ...d, costUsd: priced ? d.costUsd : null }))
    .sort((a, b) => (a.date < b.date ? -1 : 1))
  const totalsAcc = days.reduce(
    (t, d) => ({
      input: t.input + d.input,
      output: t.output + d.output,
      cacheRead: t.cacheRead + d.cacheRead,
      cacheWrite: t.cacheWrite + d.cacheWrite,
      responses: t.responses + d.responses,
      costUsd: t.costUsd + (d.costUsd ?? 0),
      unpriced: t.unpriced || d.costUsd === null,
    }),
    {
      input: 0,
      output: 0,
      cacheRead: 0,
      cacheWrite: 0,
      responses: 0,
      costUsd: 0,
      unpriced: false,
    },
  )
  const totals: UsageReport['totals'] = {
    input: totalsAcc.input,
    output: totalsAcc.output,
    cacheRead: totalsAcc.cacheRead,
    cacheWrite: totalsAcc.cacheWrite,
    responses: totalsAcc.responses,
    costUsd: totalsAcc.unpriced ? null : totalsAcc.costUsd,
  }
  return { days, totals, projection: projectUsage(days, now) }
}