wulf-pulse/lib/services/analyzer/cost-guard.ts
lorentz bd3401df1c feat(analyzer): Phase 2 — full stage persistence, fingerprints, aggregate reports, cost guards
Eight sub-phases per docs/ticket-analyzer-phase2-spec.md:

2.1 Schema (migration 070): analyzer_stage_executions table; source_snapshot,
    aggregate_fingerprint, fingerprint_generated_at columns on analyzer_analyses.
    model_traces marked LEGACY (kept for back-compat).
2.2 Every pipeline stage records a row to analyzer_stage_executions, success
    or failure. Worker persists a status='failed' analyzer_analyses row when
    the pipeline throws so partial stage records have a parent. Pipeline
    exposes raw triage/sonnet/opus responses for downstream stages.
2.3 Stage 3 prompt updated with markdown formatting rules + banned filler
    phrases. Added react-markdown + remark-gfm + @tailwindcss/typography.
    New <AnalysisMarkdown> component replaces <ProseText>; coerces stray
    headers to bold paragraphs.
2.4 Stage 6 fingerprint (Haiku) runs after persistence, failure-tolerant.
    scripts/backfill-fingerprints.ts reconstructs Stage 6 input from the
    legacy model_traces blob.
2.5 Browse UI rebuild at /analyzer/tickets: multi-select for client/issue/
    queue/status/priority/assignee, sticky filter bar, active-filter chips,
    bulk selection persisted via localStorage, "Analyze N selected" +
    "Generate aggregate report" actions. New <MultiSelect> primitive.
    Staleness uses last_activity_date > completed_at heuristic per spec C.1.
2.6 Aggregate reports (migration 071): runner is fire-and-forget, persists
    SQL distributions immediately so UI shows partial state during the
    Sonnet reduce call. Three endpoints, three pages (/analyzer/reports[/new
    /:id]). IT Glue context fetcher capped at 200 doc titles.
2.7 Cost guards (migration 072): per-request $5 confirmation, soft-warn at
    $20/day, hard-block at $50/day with ANALYZER_DAILY_COST_OVERRIDE_USERS
    override. Every gating decision audited.
2.8 Runbook + build notes updated.

128 vitest tests passing, tsc clean. Migrations 070/071/072 idempotent
(IF NOT EXISTS). model_traces double-write retained — drop in a future
migration once aggregate reports have soaked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:00:22 -04:00

155 lines
5.1 KiB
TypeScript

/**
* Cost guards for analyzer LLM operations.
*
* Spec: docs/ticket-analyzer-phase2-spec.md → Section D.8.
*
* Three thresholds:
* • $5 per request → require explicit confirmation in UI
* • $20 per user/day → soft warn (returned in preflight; UI can surface)
* • $50 per user/day → hard block (unless user is in override list)
*
* Override env var: ANALYZER_DAILY_COST_OVERRIDE_USERS (comma-separated user ids)
*/
import postgresClient from '@/lib/services/postgres-client';
export const REQUIRES_CONFIRMATION_USD = 5.0;
export const SOFT_WARN_DAILY_USD = 20.0;
export const HARD_BLOCK_DAILY_USD = 50.0;
export type CostDecision =
| 'approved'
| 'requires_confirmation'
| 'blocked'
| 'overridden';
export interface CostEvaluation {
estimatedCost: number;
dailySpendBefore: number;
decision: CostDecision;
decisionReason: string | null;
softWarn: boolean;
hardBlocked: boolean;
requiresConfirmation: boolean;
isOverride: boolean;
}
/**
* Pessimistic cost estimate for an aggregate report. Assumes Sonnet pricing
* and a payload size proportional to the number of fingerprints (each ~2KB
* after compaction). Conservative — actual cost is usually lower.
*/
export function estimateAggregateReportCost(input: {
ticketCount: number;
includeItglueContext: boolean;
}): number {
// Inputs:
// per-fingerprint input tokens ≈ 700 (compacted JSON) → 700 * N
// distributions + system prompt ≈ 2000 tokens
// IT Glue context (if included): up to 200 doc titles * 50 tokens = 10K tokens
// Outputs: cap ≈ 6K tokens (we set max_tokens 16K but real outputs are smaller).
const inputTokens =
700 * input.ticketCount +
2000 +
(input.includeItglueContext ? 10_000 : 0);
const outputTokens = 6_000;
// Sonnet pricing per Phase 1 pricing table: $3/$15 per 1M tokens.
const cost = (inputTokens / 1_000_000) * 3 + (outputTokens / 1_000_000) * 15;
return Math.round(cost * 10_000) / 10_000;
}
function getOverrideUsers(): Set<string> {
return new Set(
(process.env.ANALYZER_DAILY_COST_OVERRIDE_USERS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
);
}
export async function getUserDailySpend(userId: string | null): Promise<number> {
if (!userId) return 0;
// Sum from analyzer_aggregate_reports + analyzer_analyses for the trailing 24h.
const res = await postgresClient.query<{ total: string }>(
`SELECT COALESCE(SUM(amt), 0)::text AS total FROM (
SELECT estimated_cost_usd AS amt
FROM analyzer_aggregate_reports
WHERE generated_by_user_id = $1
AND generated_at >= NOW() - INTERVAL '24 hours'
AND estimated_cost_usd IS NOT NULL
UNION ALL
SELECT estimated_cost_usd AS amt
FROM analyzer_analyses
WHERE triggered_by_user_id = $1
AND triggered_at >= NOW() - INTERVAL '24 hours'
AND status = 'complete'
) x`,
[userId]
);
return Number(res.rows[0]?.total ?? 0);
}
export async function evaluateCost(input: {
userId: string | null;
estimatedCost: number;
confirmedCost: boolean;
}): Promise<CostEvaluation> {
const dailySpendBefore = await getUserDailySpend(input.userId);
const projectedDailySpend = dailySpendBefore + input.estimatedCost;
const overrideUsers = getOverrideUsers();
const isOverride = input.userId !== null && overrideUsers.has(input.userId);
const softWarn = projectedDailySpend >= SOFT_WARN_DAILY_USD;
const hardBlocked = projectedDailySpend >= HARD_BLOCK_DAILY_USD;
const requiresConfirmation =
input.estimatedCost > REQUIRES_CONFIRMATION_USD && !input.confirmedCost;
let decision: CostDecision;
let decisionReason: string | null = null;
if (hardBlocked && !isOverride) {
decision = 'blocked';
decisionReason = `Projected daily spend $${projectedDailySpend.toFixed(2)} would exceed hard limit $${HARD_BLOCK_DAILY_USD.toFixed(2)}`;
} else if (hardBlocked && isOverride) {
decision = 'overridden';
decisionReason = `Override allowed (user in ANALYZER_DAILY_COST_OVERRIDE_USERS); projected $${projectedDailySpend.toFixed(2)}`;
} else if (requiresConfirmation) {
decision = 'requires_confirmation';
decisionReason = `Per-request cost $${input.estimatedCost.toFixed(2)} > confirmation threshold $${REQUIRES_CONFIRMATION_USD.toFixed(2)}`;
} else {
decision = 'approved';
}
return {
estimatedCost: input.estimatedCost,
dailySpendBefore,
decision,
decisionReason,
softWarn,
hardBlocked,
requiresConfirmation,
isOverride,
};
}
export async function recordCostAuditDecision(input: {
userId: string | null;
action: string;
evaluation: CostEvaluation;
context?: Record<string, unknown>;
}): Promise<void> {
await postgresClient.query(
`INSERT INTO analyzer_cost_audit
(user_id, action, estimated_cost, daily_spend_before,
decision, decision_reason, context)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
[
input.userId,
input.action,
input.evaluation.estimatedCost,
input.evaluation.dailySpendBefore,
input.evaluation.decision,
input.evaluation.decisionReason,
JSON.stringify(input.context ?? {}),
]
);
}