feat(platform): Phase 3 — AI Budget & Cost Governance
- Scope expansion: BudgetScopeTypeSchema now includes 'org' + 'workspace' - Cost dashboard: GET /ai-budgets/costs with groupBy (model/agent/day/scope) - Aggregates totalCostUsd, totalTokens, entryCount, breakdown - Budget rollover: POST /ai-budgets/policies/:id/rollover - Computes previous period remaining, creates rollover doc, adjusts budget - GET /ai-budgets/policies/:id/rollovers for history - Enforcement check: POST /ai-budgets/check (pre-flight, no spend recorded) - Model allowlist + threshold evaluation, returns verdict + reasons - New types: CostDashboardQuerySchema, BudgetRolloverSchema - New repo functions: listAllSpendEntries, createRollover, listRollovers - New Cosmos container: ai_budget_rollovers - 1,316 tests passing (9 new)
This commit is contained in:
parent
84dc348687
commit
05acacd400
@ -3,6 +3,7 @@ import { getCollection } from '../../lib/datastore.js';
|
|||||||
import type {
|
import type {
|
||||||
BudgetAlertDoc,
|
BudgetAlertDoc,
|
||||||
BudgetPolicyDoc,
|
BudgetPolicyDoc,
|
||||||
|
BudgetRolloverDoc,
|
||||||
BudgetSpendEntryDoc,
|
BudgetSpendEntryDoc,
|
||||||
ListBudgetAlertsQuery,
|
ListBudgetAlertsQuery,
|
||||||
ListBudgetPoliciesQuery,
|
ListBudgetPoliciesQuery,
|
||||||
@ -98,3 +99,54 @@ export async function listAlerts(
|
|||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Budget Rollover ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function rolloverCollection() {
|
||||||
|
return getCollection<BudgetRolloverDoc>('ai_budget_rollovers', '/productId');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRollover(doc: BudgetRolloverDoc): Promise<BudgetRolloverDoc> {
|
||||||
|
return rolloverCollection().create(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRollovers(
|
||||||
|
productId: string,
|
||||||
|
policyId: string
|
||||||
|
): Promise<BudgetRolloverDoc[]> {
|
||||||
|
return rolloverCollection().findMany({
|
||||||
|
filter: { productId, policyId },
|
||||||
|
sort: { createdAt: -1 },
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cost Dashboard ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function listAllSpendEntries(
|
||||||
|
productId: string,
|
||||||
|
options: {
|
||||||
|
scopeType?: string;
|
||||||
|
scopeId?: string;
|
||||||
|
since?: string;
|
||||||
|
until?: string;
|
||||||
|
limit?: number;
|
||||||
|
} = {}
|
||||||
|
): Promise<BudgetSpendEntryDoc[]> {
|
||||||
|
const entries = await spendCollection().findMany({
|
||||||
|
filter: {
|
||||||
|
productId,
|
||||||
|
...(options.scopeType ? { scopeType: options.scopeType } : {}),
|
||||||
|
...(options.scopeId ? { scopeId: options.scopeId } : {}),
|
||||||
|
},
|
||||||
|
sort: { recordedAt: -1 },
|
||||||
|
limit: options.limit ?? 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter by time range in-memory
|
||||||
|
return entries.filter(e => {
|
||||||
|
if (options.since && e.recordedAt < options.since) return false;
|
||||||
|
if (options.until && e.recordedAt > options.until) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -10,6 +10,9 @@ const repoMock = {
|
|||||||
listSpendEntries: vi.fn(),
|
listSpendEntries: vi.fn(),
|
||||||
createAlerts: vi.fn(),
|
createAlerts: vi.fn(),
|
||||||
listAlerts: vi.fn(),
|
listAlerts: vi.fn(),
|
||||||
|
listAllSpendEntries: vi.fn(),
|
||||||
|
createRollover: vi.fn(),
|
||||||
|
listRollovers: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('./repository.js', () => repoMock);
|
vi.mock('./repository.js', () => repoMock);
|
||||||
@ -130,4 +133,198 @@ describe('aiBudgetRoutes', () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Scope expansion ─────────────────────────────────────
|
||||||
|
|
||||||
|
it('POST /ai-budgets/policies supports org scope', async () => {
|
||||||
|
repoMock.createPolicy.mockResolvedValue({ id: 'aibudget_2', scopeType: 'org' });
|
||||||
|
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/ai-budgets/policies',
|
||||||
|
payload: { scopeType: 'org', scopeId: 'org_1', name: 'Org Budget', budgetUsd: 500 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(repoMock.createPolicy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ scopeType: 'org', scopeId: 'org_1' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /ai-budgets/policies supports workspace scope', async () => {
|
||||||
|
repoMock.createPolicy.mockResolvedValue({ id: 'aibudget_3', scopeType: 'workspace' });
|
||||||
|
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/ai-budgets/policies',
|
||||||
|
payload: { scopeType: 'workspace', scopeId: 'ws_1', name: 'WS Budget', budgetUsd: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(repoMock.createPolicy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ scopeType: 'workspace' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Cost Dashboard ──────────────────────────────────────
|
||||||
|
|
||||||
|
it('GET /ai-budgets/costs returns grouped cost breakdown', async () => {
|
||||||
|
repoMock.listAllSpendEntries.mockResolvedValue([
|
||||||
|
{ costUsd: 5, tokensUsed: 100, model: 'gpt-4o', recordedAt: '2026-03-15T00:00:00Z' },
|
||||||
|
{ costUsd: 3, tokensUsed: 80, model: 'gpt-4o', recordedAt: '2026-03-15T01:00:00Z' },
|
||||||
|
{ costUsd: 1, tokensUsed: 50, model: 'gpt-4o-mini', recordedAt: '2026-03-15T02:00:00Z' },
|
||||||
|
]);
|
||||||
|
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/ai-budgets/costs?groupBy=model' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.totalCostUsd).toBe(9);
|
||||||
|
expect(body.totalTokens).toBe(230);
|
||||||
|
expect(body.breakdown).toHaveLength(2);
|
||||||
|
expect(body.breakdown[0].key).toBe('gpt-4o');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /ai-budgets/costs groups by day', async () => {
|
||||||
|
repoMock.listAllSpendEntries.mockResolvedValue([
|
||||||
|
{ costUsd: 5, tokensUsed: 100, recordedAt: '2026-03-15T00:00:00Z' },
|
||||||
|
{ costUsd: 3, tokensUsed: 80, recordedAt: '2026-03-16T01:00:00Z' },
|
||||||
|
]);
|
||||||
|
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/ai-budgets/costs?groupBy=day' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().breakdown).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Budget Rollover ─────────────────────────────────────
|
||||||
|
|
||||||
|
it('POST /ai-budgets/policies/:id/rollover creates rollover and adjusts budget', async () => {
|
||||||
|
repoMock.getPolicy.mockResolvedValue({
|
||||||
|
id: 'aibudget_1',
|
||||||
|
productId: 'lysnrai',
|
||||||
|
period: 'monthly',
|
||||||
|
budgetUsd: 100,
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
repoMock.listSpendEntries.mockResolvedValue([
|
||||||
|
{ costUsd: 60, recordedAt: '2026-02-10T00:00:00Z' },
|
||||||
|
]);
|
||||||
|
repoMock.createRollover.mockImplementation(async (doc: Record<string, unknown>) => doc);
|
||||||
|
repoMock.updatePolicy.mockResolvedValue({ budgetUsd: 140 });
|
||||||
|
|
||||||
|
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/ai-budgets/policies/aibudget_1/rollover',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(repoMock.createRollover).toHaveBeenCalled();
|
||||||
|
expect(repoMock.updatePolicy).toHaveBeenCalledWith(
|
||||||
|
'aibudget_1',
|
||||||
|
'lysnrai',
|
||||||
|
expect.objectContaining({ budgetUsd: expect.any(Number) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /ai-budgets/policies/:id/rollovers returns rollover history', async () => {
|
||||||
|
repoMock.listRollovers.mockResolvedValue([
|
||||||
|
{ id: 'airollover_1', policyId: 'aibudget_1', rolledOverUsd: 40 },
|
||||||
|
]);
|
||||||
|
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/ai-budgets/policies/aibudget_1/rollovers',
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Enforcement Check ───────────────────────────────────
|
||||||
|
|
||||||
|
it('POST /ai-budgets/check returns allow when under budget', async () => {
|
||||||
|
repoMock.listPolicies.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'aibudget_1',
|
||||||
|
status: 'active',
|
||||||
|
period: 'monthly',
|
||||||
|
budgetUsd: 100,
|
||||||
|
softThreshold: 0.8,
|
||||||
|
hardThreshold: 1,
|
||||||
|
modelAllowlist: [],
|
||||||
|
name: 'Test',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
repoMock.listSpendEntries.mockResolvedValue([
|
||||||
|
{ costUsd: 20, recordedAt: '2026-03-15T00:00:00Z' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/ai-budgets/check',
|
||||||
|
payload: { scopeType: 'agent', scopeId: 'agt_1', estimatedCostUsd: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().verdict).toBe('allow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /ai-budgets/check returns block when model not allowed', async () => {
|
||||||
|
repoMock.listPolicies.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'aibudget_1',
|
||||||
|
status: 'active',
|
||||||
|
period: 'monthly',
|
||||||
|
budgetUsd: 100,
|
||||||
|
softThreshold: 0.8,
|
||||||
|
hardThreshold: 1,
|
||||||
|
modelAllowlist: ['gpt-4o-mini'],
|
||||||
|
name: 'Test',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/ai-budgets/check',
|
||||||
|
payload: { scopeType: 'agent', scopeId: 'agt_1', estimatedCostUsd: 5, model: 'gpt-4.1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().verdict).toBe('block');
|
||||||
|
expect(res.json().reasons[0]).toContain('not in allowlist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /ai-budgets/check returns warn when approaching soft threshold', async () => {
|
||||||
|
repoMock.listPolicies.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'aibudget_1',
|
||||||
|
status: 'active',
|
||||||
|
period: 'monthly',
|
||||||
|
budgetUsd: 100,
|
||||||
|
softThreshold: 0.8,
|
||||||
|
hardThreshold: 1,
|
||||||
|
modelAllowlist: [],
|
||||||
|
name: 'Test',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
repoMock.listSpendEntries.mockResolvedValue([
|
||||||
|
{ costUsd: 75, recordedAt: '2026-03-15T00:00:00Z' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/ai-budgets/check',
|
||||||
|
payload: { scopeType: 'agent', scopeId: 'agt_1', estimatedCostUsd: 10 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().verdict).toBe('warn');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,7 +4,9 @@ import { BadRequestError, ForbiddenError } from '../../lib/errors.js';
|
|||||||
import {
|
import {
|
||||||
BudgetAlertDoc,
|
BudgetAlertDoc,
|
||||||
BudgetPolicyDoc,
|
BudgetPolicyDoc,
|
||||||
|
BudgetRolloverDoc,
|
||||||
BudgetSpendEntryDoc,
|
BudgetSpendEntryDoc,
|
||||||
|
CostDashboardQuerySchema,
|
||||||
CreateBudgetPolicySchema,
|
CreateBudgetPolicySchema,
|
||||||
ListBudgetAlertsQuerySchema,
|
ListBudgetAlertsQuerySchema,
|
||||||
ListBudgetPoliciesQuerySchema,
|
ListBudgetPoliciesQuerySchema,
|
||||||
@ -248,4 +250,197 @@ export async function aiBudgetRoutes(app: FastifyInstance) {
|
|||||||
alerts,
|
alerts,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Cost Dashboard ──────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/ai-budgets/costs', async req => {
|
||||||
|
const access = requireAdmin(req);
|
||||||
|
const parsed = CostDashboardQuerySchema.safeParse(req.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
validationError(parsed.error.issues.map(issue => issue.message).join('; '));
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await repo.listAllSpendEntries(access.productId, {
|
||||||
|
scopeType: parsed.data.scopeType,
|
||||||
|
scopeId: parsed.data.scopeId,
|
||||||
|
since: parsed.data.since,
|
||||||
|
until: parsed.data.until,
|
||||||
|
limit: parsed.data.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCostUsd = entries.reduce((sum, e) => sum + e.costUsd, 0);
|
||||||
|
const totalTokens = entries.reduce((sum, e) => sum + e.tokensUsed, 0);
|
||||||
|
|
||||||
|
// Group by requested dimension
|
||||||
|
const groups = new Map<string, { costUsd: number; tokensUsed: number; count: number }>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
let key: string;
|
||||||
|
switch (parsed.data.groupBy) {
|
||||||
|
case 'model':
|
||||||
|
key = entry.model ?? 'unknown';
|
||||||
|
break;
|
||||||
|
case 'agent':
|
||||||
|
key = entry.agentId ?? 'unknown';
|
||||||
|
break;
|
||||||
|
case 'day':
|
||||||
|
key = entry.recordedAt.slice(0, 10);
|
||||||
|
break;
|
||||||
|
case 'scope':
|
||||||
|
key = `${entry.scopeType}:${entry.scopeId}`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
key = 'all';
|
||||||
|
}
|
||||||
|
const existing = groups.get(key) ?? { costUsd: 0, tokensUsed: 0, count: 0 };
|
||||||
|
existing.costUsd += entry.costUsd;
|
||||||
|
existing.tokensUsed += entry.tokensUsed;
|
||||||
|
existing.count++;
|
||||||
|
groups.set(key, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const breakdown = Array.from(groups.entries())
|
||||||
|
.map(([key, data]) => ({ key, ...data }))
|
||||||
|
.sort((a, b) => b.costUsd - a.costUsd);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCostUsd: Math.round(totalCostUsd * 10000) / 10000,
|
||||||
|
totalTokens,
|
||||||
|
entryCount: entries.length,
|
||||||
|
groupBy: parsed.data.groupBy,
|
||||||
|
breakdown,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Budget Rollover ─────────────────────────────────────
|
||||||
|
|
||||||
|
app.post('/ai-budgets/policies/:id/rollover', async req => {
|
||||||
|
const access = requireAdmin(req);
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const policy = await repo.getPolicy(id, access.productId);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const currentPeriodStart = toIsoDayBoundary(periodStart(now, policy.period));
|
||||||
|
|
||||||
|
// Calculate previous period
|
||||||
|
const prev = new Date(now);
|
||||||
|
if (policy.period === 'monthly') {
|
||||||
|
prev.setUTCMonth(prev.getUTCMonth() - 1);
|
||||||
|
} else {
|
||||||
|
prev.setUTCDate(prev.getUTCDate() - 1);
|
||||||
|
}
|
||||||
|
const prevPeriodStart = toIsoDayBoundary(periodStart(prev, policy.period));
|
||||||
|
|
||||||
|
// Get spend from previous period
|
||||||
|
const prevEntries = await repo.listSpendEntries(access.productId, {
|
||||||
|
policyId: id,
|
||||||
|
since: prevPeriodStart,
|
||||||
|
});
|
||||||
|
// Only count entries in previous period (before current period)
|
||||||
|
const prevPeriodEntries = prevEntries.filter(e => e.recordedAt < currentPeriodStart);
|
||||||
|
const spentUsd = prevPeriodEntries.reduce((sum, e) => sum + e.costUsd, 0);
|
||||||
|
const remainingUsd = Math.max(0, policy.budgetUsd - spentUsd);
|
||||||
|
const rolledOverUsd = remainingUsd; // Full rollover — could add a cap later
|
||||||
|
|
||||||
|
const rollover: BudgetRolloverDoc = {
|
||||||
|
id: `airollover_${randomUUID()}`,
|
||||||
|
productId: access.productId,
|
||||||
|
policyId: id,
|
||||||
|
fromPeriod: prevPeriodStart,
|
||||||
|
toPeriod: currentPeriodStart,
|
||||||
|
spentUsd,
|
||||||
|
budgetUsd: policy.budgetUsd,
|
||||||
|
remainingUsd,
|
||||||
|
rolledOverUsd,
|
||||||
|
createdAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const saved = await repo.createRollover(rollover);
|
||||||
|
|
||||||
|
// Temporarily increase budget for current period
|
||||||
|
await repo.updatePolicy(id, access.productId, {
|
||||||
|
budgetUsd: policy.budgetUsd + rolledOverUsd,
|
||||||
|
metadata: {
|
||||||
|
...(policy.metadata ?? {}),
|
||||||
|
lastRollover: saved.id,
|
||||||
|
originalBudgetUsd: policy.budgetUsd,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rollover: saved,
|
||||||
|
newBudgetUsd: policy.budgetUsd + rolledOverUsd,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/ai-budgets/policies/:id/rollovers', async req => {
|
||||||
|
const access = requireAdmin(req);
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
return repo.listRollovers(access.productId, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Budget Enforcement Check ────────────────────────────
|
||||||
|
// Lightweight endpoint for pre-flight budget checks (no spend recorded)
|
||||||
|
|
||||||
|
app.post('/ai-budgets/check', async req => {
|
||||||
|
const access = requireAdmin(req);
|
||||||
|
const body = req.body as {
|
||||||
|
scopeType: string;
|
||||||
|
scopeId: string;
|
||||||
|
model?: string;
|
||||||
|
estimatedCostUsd: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!body.scopeType || !body.scopeId || body.estimatedCostUsd === undefined) {
|
||||||
|
validationError('scopeType, scopeId, and estimatedCostUsd are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const policies = await repo.listPolicies(access.productId, {
|
||||||
|
scopeType: body.scopeType as 'product' | 'agent' | 'org' | 'workspace',
|
||||||
|
scopeId: body.scopeId,
|
||||||
|
status: 'active',
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
let verdict: 'allow' | 'warn' | 'block' = 'allow';
|
||||||
|
const reasons: string[] = [];
|
||||||
|
|
||||||
|
for (const policy of policies) {
|
||||||
|
// Model allowlist check
|
||||||
|
if (body.model && policy.modelAllowlist.length > 0) {
|
||||||
|
if (!policy.modelAllowlist.includes(body.model)) {
|
||||||
|
verdict = 'block';
|
||||||
|
reasons.push(`Model '${body.model}' not in allowlist for '${policy.name}'`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const since = toIsoDayBoundary(periodStart(now, policy.period));
|
||||||
|
const entries = await repo.listSpendEntries(access.productId, {
|
||||||
|
policyId: policy.id,
|
||||||
|
since,
|
||||||
|
});
|
||||||
|
const currentSpend = entries.reduce((sum, e) => sum + e.costUsd, 0);
|
||||||
|
const projectedSpend = currentSpend + body.estimatedCostUsd;
|
||||||
|
const percentUsed = policy.budgetUsd > 0 ? projectedSpend / policy.budgetUsd : 0;
|
||||||
|
|
||||||
|
if (percentUsed >= policy.hardThreshold) {
|
||||||
|
verdict = 'block';
|
||||||
|
reasons.push(
|
||||||
|
`Would exceed hard threshold (${Math.round(percentUsed * 100)}%) for '${policy.name}'`
|
||||||
|
);
|
||||||
|
} else if (percentUsed >= policy.softThreshold && verdict !== 'block') {
|
||||||
|
verdict = 'warn';
|
||||||
|
reasons.push(
|
||||||
|
`Would exceed soft threshold (${Math.round(percentUsed * 100)}%) for '${policy.name}'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
verdict,
|
||||||
|
reasons,
|
||||||
|
policiesEvaluated: policies.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const BudgetScopeTypeSchema = z.enum(['product', 'agent']);
|
export const BudgetScopeTypeSchema = z.enum(['product', 'agent', 'org', 'workspace']);
|
||||||
export const BudgetPolicyStatusSchema = z.enum(['active', 'paused', 'archived']);
|
export const BudgetPolicyStatusSchema = z.enum(['active', 'paused', 'archived']);
|
||||||
export const BudgetPeriodSchema = z.enum(['daily', 'monthly']);
|
export const BudgetPeriodSchema = z.enum(['daily', 'monthly']);
|
||||||
export const BudgetAlertSeveritySchema = z.enum(['warn', 'block']);
|
export const BudgetAlertSeveritySchema = z.enum(['warn', 'block']);
|
||||||
@ -122,3 +122,36 @@ export const ListBudgetAlertsQuerySchema = z.object({
|
|||||||
|
|
||||||
export type ListBudgetPoliciesQuery = z.infer<typeof ListBudgetPoliciesQuerySchema>;
|
export type ListBudgetPoliciesQuery = z.infer<typeof ListBudgetPoliciesQuerySchema>;
|
||||||
export type ListBudgetAlertsQuery = z.infer<typeof ListBudgetAlertsQuerySchema>;
|
export type ListBudgetAlertsQuery = z.infer<typeof ListBudgetAlertsQuerySchema>;
|
||||||
|
|
||||||
|
// ── Cost Dashboard ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export const CostDashboardQuerySchema = z.object({
|
||||||
|
scopeType: BudgetScopeTypeSchema.optional(),
|
||||||
|
scopeId: z.string().optional(),
|
||||||
|
since: z.string().optional(),
|
||||||
|
until: z.string().optional(),
|
||||||
|
groupBy: z.enum(['model', 'agent', 'day', 'scope']).default('model'),
|
||||||
|
limit: z.coerce.number().min(1).max(500).default(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CostDashboardQuery = z.infer<typeof CostDashboardQuerySchema>;
|
||||||
|
|
||||||
|
// ── Budget Rollover ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export const BudgetRolloverSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
productId: z.string().min(1),
|
||||||
|
policyId: z.string().min(1),
|
||||||
|
fromPeriod: z.string().min(1),
|
||||||
|
toPeriod: z.string().min(1),
|
||||||
|
spentUsd: z.number().min(0),
|
||||||
|
budgetUsd: z.number().min(0),
|
||||||
|
remainingUsd: z.number(),
|
||||||
|
rolledOverUsd: z.number().min(0),
|
||||||
|
createdAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BudgetRolloverDoc = z.infer<typeof BudgetRolloverSchema> & {
|
||||||
|
_ts?: number;
|
||||||
|
_etag?: string;
|
||||||
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user