From be371474c44041ffd34c61f831bba25952ac53f6 Mon Sep 17 00:00:00 2001 From: "evlogai[bot]" Date: Thu, 8 Oct 2026 08:11:05 +0000 Subject: [PATCH] fix(evi): reject a spend-report window that ends in the future --- apps/evi/agent/lib/gateway.test.ts | 38 ++++++++++++++++++++++++++++++ apps/evi/agent/lib/gateway.ts | 29 +++++++++++++++++++++++ apps/evi/agent/tools/ai-gateway.ts | 8 +++---- 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/apps/evi/agent/lib/gateway.test.ts b/apps/evi/agent/lib/gateway.test.ts index bda09a03..350666c0 100644 --- a/apps/evi/agent/lib/gateway.test.ts +++ b/apps/evi/agent/lib/gateway.test.ts @@ -117,6 +117,44 @@ describe('reportQuery', () => { }) }) +describe('reportWindowProblem', () => { + it('accepts a window that ends today', async () => { + const { reportWindowProblem } = await loadGateway({}) + expect(reportWindowProblem('2026-10-01', '2026-10-08', '2026-10-08')).toBeUndefined() + }) + + it('rejects a window whose end date has not happened yet', async () => { + const { reportWindowProblem } = await loadGateway({}) + expect(reportWindowProblem('2026-02-07', '2027-03-07', '2026-10-08')).toEqual({ + field: 'endDate', + message: 'endDate is in the future: the window ends 2027-03-07 but today is 2026-10-08 (UTC), and the gateway has no data for dates that have not happened yet', + }) + }) + + it('rejects an inverted window', async () => { + const { reportWindowProblem } = await loadGateway({}) + expect(reportWindowProblem('2026-10-08', '2026-10-01', '2026-10-08')).toEqual({ + field: 'startDate', + message: 'startDate must not be later than endDate', + }) + }) +}) + +describe('todayUtc', () => { + it('returns the UTC calendar date regardless of the local clock', async () => { + const { todayUtc } = await loadGateway({}) + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-10-08T23:30:00Z')) + expect(todayUtc()).toBe('2026-10-08') + vi.setSystemTime(new Date('2026-10-09T00:30:00Z')) + expect(todayUtc()).toBe('2026-10-09') + } finally { + vi.useRealTimers() + } + }) +}) + describe('scopedReport', () => { const payload = { results: [ diff --git a/apps/evi/agent/lib/gateway.ts b/apps/evi/agent/lib/gateway.ts index 7d479e0c..a1c58405 100644 --- a/apps/evi/agent/lib/gateway.ts +++ b/apps/evi/agent/lib/gateway.ts @@ -89,6 +89,35 @@ export function reportApiKeyName(): string | undefined { return process.env.AI_GATEWAY_REPORT_API_KEY_NAME?.trim() || undefined } +/** + * Today's date in UTC, `YYYY-MM-DD`: the reference spend-report windows are + * validated against. + */ +export function todayUtc(): string { + return new Date().toISOString().slice(0, 10) +} + +/** + * Why a spend-report window is invalid, or undefined when it is fine. The + * gateway silently answers an out-of-range window with zero rows, and the + * empty-scope note then reads as a tagging gap, so the window is rejected up + * front instead. + */ +export function reportWindowProblem( + startDate: string, + endDate: string, + today = todayUtc(), +): { field: 'startDate' | 'endDate', message: string } | undefined { + if (startDate > endDate) return { field: 'startDate', message: 'startDate must not be later than endDate' } + if (endDate > today) { + return { + field: 'endDate', + message: `endDate is in the future: the window ends ${endDate} but today is ${today} (UTC), and the gateway has no data for dates that have not happened yet`, + } + } + return undefined +} + /** The caller-supplied part of a spend-report request. */ export interface ReportQueryInput { groupBy?: string diff --git a/apps/evi/agent/tools/ai-gateway.ts b/apps/evi/agent/tools/ai-gateway.ts index 4b5e2b5b..46acd430 100644 --- a/apps/evi/agent/tools/ai-gateway.ts +++ b/apps/evi/agent/tools/ai-gateway.ts @@ -2,7 +2,7 @@ import { useLogger } from 'evlog/eve' import { defineDynamic, defineTool } from 'eve/tools' import { z } from 'zod' import { eviErrors, refusal } from '../lib/errors' -import { defaultReportTag, gatewayFetch, reportQuery, scopedReport } from '../lib/gateway' +import { defaultReportTag, gatewayFetch, reportQuery, reportWindowProblem, scopedReport } from '../lib/gateway' import { canAccessAdminTools } from '../lib/trust' function notAvailable(tool: string) { @@ -39,9 +39,9 @@ export default defineDynamic({ credentialType: z.enum(['byok', 'system']).optional(), tags: z.array(z.string()).optional().describe('Override default report tags (env AI_GATEWAY_REPORT_TAGS / default evi:env:). Ignored while key-name scoping applies, i.e. AI_GATEWAY_REPORT_API_KEY_NAME is set and no groupBy was requested.'), tagsMatch: z.enum(['any', 'all']).optional(), - }).refine(({ startDate, endDate }) => startDate <= endDate, { - message: 'startDate must not be later than endDate', - path: ['startDate'], + }).superRefine(({ startDate, endDate }, ctx) => { + const problem = reportWindowProblem(startDate, endDate) + if (problem) ctx.addIssue({ code: 'custom', message: problem.message, path: [problem.field] }) }), async execute(input, toolCtx) { if (!canAccessAdminTools(toolCtx.session.auth.current)) return notAvailable('ai_gateway__report')