From 4c5503976374acc70df919988880942b6d6be8a0 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 4 Feb 2026 17:24:36 -0600 Subject: [PATCH 1/2] feat(abuse): implement cost reporting for spend-based heuristics Add functionality to report actual request costs back to the abuse service. This enables the abuse service to track real-time spend and trigger heuristics like free tier exhaustion. --- src/app/api/openrouter/[...path]/route.ts | 39 ++++--- src/lib/abuse-service.ts | 135 ++++++++++++++++++++++ src/lib/processUsage.ts | 9 ++ 3 files changed, 169 insertions(+), 14 deletions(-) diff --git a/src/app/api/openrouter/[...path]/route.ts b/src/app/api/openrouter/[...path]/route.ts index 4444ab98e3..76c5b233db 100644 --- a/src/app/api/openrouter/[...path]/route.ts +++ b/src/app/api/openrouter/[...path]/route.ts @@ -205,26 +205,15 @@ export async function POST(request: NextRequest): Promise { - if (result) { - console.log('Abuse classification result:', { - verdict: result.verdict, - risk_score: result.risk_score, - signals: result.signals, - identity_key: result.context.identity_key, - kilo_user_id: user.id, - requested_model: originalModelIdLowerCased, - rps: result.context.requests_per_second, - }); - } }); + // large responses may run longer than the 800s serverless function timeout, usually this value is set to 8192 tokens if (requestBodyParsed.max_tokens && requestBodyParsed.max_tokens > MAX_TOKENS_LIMIT) { console.warn(`SECURITY: Max tokens limit exceeded: ${user.id}`, { @@ -392,6 +381,28 @@ export async function POST(request: NextRequest): Promise | undefined; + const classifyResult = await Promise.race([ + classifyPromise.finally(() => timeoutId && clearTimeout(timeoutId)), + new Promise(resolve => { + timeoutId = setTimeout(() => resolve(null), 2000); + }), + ]); + if (classifyResult) { + console.log('Abuse classification result:', { + verdict: classifyResult.verdict, + risk_score: classifyResult.risk_score, + signals: classifyResult.signals, + identity_key: classifyResult.context.identity_key, + kilo_user_id: user.id, + requested_model: originalModelIdLowerCased, + rps: classifyResult.context.requests_per_second, + request_id: classifyResult.request_id, + }); + usageContext.abuse_request_id = classifyResult.request_id; + } + accountForMicrodollarUsage(clonedReponse, usageContext, openrouterRequestSpan); { diff --git a/src/lib/abuse-service.ts b/src/lib/abuse-service.ts index 1b777e2fa6..d0771bc916 100644 --- a/src/lib/abuse-service.ts +++ b/src/lib/abuse-service.ts @@ -117,6 +117,8 @@ export type AbuseClassificationResponse = { action_metadata: ActionMetadata; /** State context for debugging headers */ context: ClassificationContext; + /** Request ID for correlating with cost updates. 0 indicates an error during classification. */ + request_id: number; }; /** @@ -238,6 +240,87 @@ export async function classifyRequest( } } +/** + * Request payload for reporting cost to the abuse service after request completion. + * Enables spend-based heuristics like free_tier_exhausted. + */ +type CostUpdatePayload = { + // Identity fields (must match what was sent to /classify) + kilo_user_id?: string | null; + ip_address?: string | null; + ja4_digest?: string | null; + user_agent?: string | null; + + // Request identification (REQUIRED) + request_id: number; // From classify response, for correlation + message_id: string; // From LLM response, for analytics + + // Cost data (REQUIRED, in microdollars) + cost: number; + requested_model?: string | null; + + // Token counts (optional but recommended) + input_tokens?: number | null; + output_tokens?: number | null; + cache_write_tokens?: number | null; + cache_hit_tokens?: number | null; +}; + +/** + * Response from the cost update endpoint + */ +export type CostUpdateResponse = { + success: boolean; + identity_key?: string; + message_id?: string; + do_updated?: boolean; + error?: string; +}; + +/** + * Report cost to the abuse service after a request completes. + * This enables spend-based heuristics like free_tier_exhausted. + * + * This is fire-and-forget - failures are logged but don't affect the user. + * + * @param payload - Cost and identity data to report + * @returns Response or null if service unavailable/failed + */ +export async function reportCost(payload: CostUpdatePayload): Promise { + if (!ABUSE_SERVICE_URL) { + return null; + } + + try { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + // Add Cloudflare Access headers in production + if (ABUSE_SERVICE_CF_ACCESS_CLIENT_ID && ABUSE_SERVICE_CF_ACCESS_CLIENT_SECRET) { + headers['CF-Access-Client-Id'] = ABUSE_SERVICE_CF_ACCESS_CLIENT_ID; + headers['CF-Access-Client-Secret'] = ABUSE_SERVICE_CF_ACCESS_CLIENT_SECRET; + } + + const response = await fetch(`${ABUSE_SERVICE_URL}/api/usage/cost`, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + console.error(`[Abuse] Cost update failed (${response.status}): ${await response.text()}`); + return null; + } + + return (await response.json()) as CostUpdateResponse; + } catch (error) { + // Log but don't throw - this shouldn't affect user experience + console.error('[Abuse] Failed to report cost:', error); + return null; + } +} + /** * Context needed to classify abuse for a request. * All fields are optional to allow classification early in the request lifecycle. @@ -292,3 +375,55 @@ export async function classifyAbuse( return classifyRequest(payload); } + +/** + * Report cost to the abuse service after a request completes. + * Call this after the LLM response is processed and usage stats are available. + * + * Requires usageContext.abuse_request_id (from classify response) and + * usageStats.messageId (from LLM response). Skips if either is missing + * or if abuse_request_id is 0 (indicates classification error). + * + * Use fire-and-forget pattern since this shouldn't block: + * reportAbuseCost(usageContext, usageStats).catch(console.error) + */ +export async function reportAbuseCost( + usageContext: { + kiloUserId: string; + fraudHeaders: { + http_x_forwarded_for: string | null; + http_x_vercel_ja4_digest: string | null; + http_user_agent: string | null; + }; + requested_model: string; + abuse_request_id?: number; + }, + usageStats: { + messageId: string | null; + cost_mUsd: number; + inputTokens: number; + outputTokens: number; + cacheWriteTokens: number; + cacheHitTokens: number; + } +): Promise { + // Skip if missing required fields or request_id is 0 (classification error) + if (!usageContext.abuse_request_id || !usageStats.messageId) { + return null; + } + + return reportCost({ + kilo_user_id: usageContext.kiloUserId, + ip_address: usageContext.fraudHeaders.http_x_forwarded_for, + ja4_digest: usageContext.fraudHeaders.http_x_vercel_ja4_digest, + user_agent: usageContext.fraudHeaders.http_user_agent, + request_id: usageContext.abuse_request_id, + message_id: usageStats.messageId, + cost: usageStats.cost_mUsd, + requested_model: usageContext.requested_model, + input_tokens: usageStats.inputTokens, + output_tokens: usageStats.outputTokens, + cache_write_tokens: usageStats.cacheWriteTokens, + cache_hit_tokens: usageStats.cacheHitTokens, + }); +} diff --git a/src/lib/processUsage.ts b/src/lib/processUsage.ts index 935f4f4d34..73fc0095fa 100644 --- a/src/lib/processUsage.ts +++ b/src/lib/processUsage.ts @@ -27,6 +27,7 @@ import { maybeIssueKiloPassBonusFromUsageThreshold } from '@/lib/kilo-pass/usage import { getEffectiveKiloPassThreshold } from '@/lib/kilo-pass/threshold'; import { appendKiloPassAuditLog } from '@/lib/kilo-pass/issuance'; import { KiloPassAuditLogAction, KiloPassAuditLogResult } from '@/lib/kilo-pass/enums'; +import { reportAbuseCost } from '@/lib/abuse-service'; const posthogClient = PostHogClient(); @@ -171,6 +172,8 @@ export type MicrodollarUsageContext = { /** True if user/org is using their own API key - cost should be zeroed out */ user_byok: boolean; has_tools: boolean; + /** Request ID from abuse service classify response, for cost tracking correlation. 0 means skip. */ + abuse_request_id?: number; }; export type UsageContextInfo = ReturnType; @@ -916,6 +919,12 @@ async function processTokenData( usageStats.model = usageContext.requested_model; } + // Report upstream cost to abuse service BEFORE zeroing for free/BYOK + // (abuse service needs actual spend for heuristics like free_tier_exhausted) + reportAbuseCost(usageContext, usageStats).catch(error => { + console.error('[Abuse] Failed to report cost:', error); + }); + if (isFreeModel(usageContext.requested_model) || usageContext.user_byok) { usageStats.cost_mUsd = 0; usageStats.cacheDiscount_mUsd = 0; From 5abaffd9b8addecc397e29591b5009bfc25503e5 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 4 Feb 2026 17:38:57 -0600 Subject: [PATCH 2/2] refactor(abuse): remove legacy service secret Remove the ABUSE_SERVICE_SECRET and its associated X-Service-Secret header. Authentication is now managed via Cloudflare Access headers. --- src/lib/abuse-service.ts | 9 +-------- src/lib/config.server.ts | 1 - 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/lib/abuse-service.ts b/src/lib/abuse-service.ts index d0771bc916..1085779512 100644 --- a/src/lib/abuse-service.ts +++ b/src/lib/abuse-service.ts @@ -4,7 +4,6 @@ import { type NextRequest } from 'next/server'; import { - ABUSE_SERVICE_SECRET, ABUSE_SERVICE_CF_ACCESS_CLIENT_ID, ABUSE_SERVICE_CF_ACCESS_CLIENT_SECRET, ABUSE_SERVICE_URL, @@ -204,18 +203,12 @@ export async function classifyRequest( return null; } - if (!ABUSE_SERVICE_SECRET) { - console.warn('ABUSE_SERVICE_SECRET not configured, skipping abuse classification'); - return null; - } - try { const headers: Record = { 'Content-Type': 'application/json', - 'X-Service-Secret': ABUSE_SERVICE_SECRET, }; - // Add Cloudflare Access headers in production (validated at startup in config.server.ts) + // Add Cloudflare Access headers for authentication if (ABUSE_SERVICE_CF_ACCESS_CLIENT_ID && ABUSE_SERVICE_CF_ACCESS_CLIENT_SECRET) { headers['CF-Access-Client-Id'] = ABUSE_SERVICE_CF_ACCESS_CLIENT_ID; headers['CF-Access-Client-Secret'] = ABUSE_SERVICE_CF_ACCESS_CLIENT_SECRET; diff --git a/src/lib/config.server.ts b/src/lib/config.server.ts index 8786e3b272..87e9fdb7d1 100644 --- a/src/lib/config.server.ts +++ b/src/lib/config.server.ts @@ -109,7 +109,6 @@ export const ENABLE_MILVUS_DUAL_WRITE = true; export const AI_ATTRIBUTION_ADMIN_SECRET = getEnvVariable('AI_ATTRIBUTION_ADMIN_SECRET'); // Abuse Detection Service -export const ABUSE_SERVICE_SECRET = getEnvVariable('ABUSE_SERVICE_SECRET'); export const ABUSE_SERVICE_CF_ACCESS_CLIENT_ID = getEnvVariable( 'ABUSE_SERVICE_CF_ACCESS_CLIENT_ID' );