diff --git a/apps/web/src/app/api/openrouter/[...path]/route.test.ts b/apps/web/src/app/api/openrouter/[...path]/route.test.ts index 6379476772..46b82828a2 100644 --- a/apps/web/src/app/api/openrouter/[...path]/route.test.ts +++ b/apps/web/src/app/api/openrouter/[...path]/route.test.ts @@ -20,7 +20,6 @@ import { getOpenRouterModelsFromDatabase, isValidOpenRouterModelId, } from '@/lib/ai-gateway/providers/gateway-models-cache'; -import { emitApiMetricsForResponse } from '@/lib/ai-gateway/o11y/api-metrics.server'; import { accountForMicrodollarUsage, INVALID_TOKEN_CODE } from '@/lib/ai-gateway/llm-proxy-helpers'; import { ReasoningDetailsTransform, type Provider } from '@/lib/ai-gateway/providers/types'; import { fetchEfficientAutoDecision } from '@/lib/ai-gateway/auto-routing-decision'; @@ -82,11 +81,6 @@ jest.mock('@/lib/ai-gateway/providers/direct-byok', () => ({ })); jest.mock('@/lib/ai-gateway/providers/upstream-request'); jest.mock('@/lib/ai-gateway/providers/gateway-models-cache'); -jest.mock('@/lib/ai-gateway/o11y/api-metrics.server', () => ({ - emitApiMetricsForResponse: jest.fn(), - getToolsAvailable: jest.fn(() => false), - getToolsUsed: jest.fn(() => false), -})); jest.mock('@/lib/ai-gateway/rewriteModelResponse', () => { const actual = jest.requireActual('@/lib/ai-gateway/rewriteModelResponse'); const { wrapInSafeNextResponse } = jest.requireActual('@/lib/ai-gateway/llm-proxy-helpers'); @@ -133,7 +127,6 @@ const mockedGetProvider = jest.mocked(getProvider); const mockedUpstreamRequest = jest.mocked(upstreamRequest); const mockedGetOpenRouterModels = jest.mocked(getOpenRouterModelsFromDatabase); const mockedIsValidOpenRouterModelId = jest.mocked(isValidOpenRouterModelId); -const mockedEmitApiMetricsForResponse = jest.mocked(emitApiMetricsForResponse); const mockedAccountForMicrodollarUsage = jest.mocked(accountForMicrodollarUsage); const mockedFetchEfficientAutoDecision = jest.mocked(fetchEfficientAutoDecision); const mockedCollectDeniedAutoRoutingModelIds = jest.mocked(collectDeniedAutoRoutingModelIds); @@ -267,7 +260,6 @@ describe('POST /api/openrouter/v1/chat/completions bearer audiences', () => { type: 'success', response: upstreamJsonResponse({ id: 'chatcmpl-1', model: 'openai/gpt-4o', choices: [] }), }); - mockedEmitApiMetricsForResponse.mockReturnValue(undefined); mockedAccountForMicrodollarUsage.mockReturnValue(undefined); }); @@ -568,7 +560,6 @@ describe('POST /api/openrouter/v1/chat/completions request handling', () => { type: 'success', response: upstreamJsonResponse({ id: 'chatcmpl-1', model: 'openai/gpt-4o', choices: [] }), }); - mockedEmitApiMetricsForResponse.mockReturnValue(undefined); mockedAccountForMicrodollarUsage.mockReturnValue(undefined); }); @@ -916,7 +907,6 @@ describe('kilo-auto/efficient classifier billing', () => { choices: [], }), }); - mockedEmitApiMetricsForResponse.mockReturnValue(undefined); mockedAccountForMicrodollarUsage.mockReturnValue(undefined); mockedLogMicrodollarUsage.mockResolvedValue(null); mockedGetEffectiveModelDecision.mockResolvedValue({ allowed: true }); @@ -1385,7 +1375,6 @@ describe('auto-routing shadow classifier', () => { type: 'success', response: upstreamJsonResponse({ id: 'chatcmpl-1', model: 'openai/gpt-4o', choices: [] }), }); - mockedEmitApiMetricsForResponse.mockReturnValue(undefined); mockedAccountForMicrodollarUsage.mockReturnValue(undefined); mockedApplyResolvedAutoModel.mockImplementation(async (opts, request) => { if (opts.efficientDecision) await opts.efficientDecision(); diff --git a/apps/web/src/app/api/openrouter/[...path]/route.ts b/apps/web/src/app/api/openrouter/[...path]/route.ts index 18bbe174a7..3af69ada8f 100644 --- a/apps/web/src/app/api/openrouter/[...path]/route.ts +++ b/apps/web/src/app/api/openrouter/[...path]/route.ts @@ -77,7 +77,6 @@ import { checkPromotionLimit, } from '@/lib/free-model-rate-limiter'; import { PROMOTION_MAX_REQUESTS, PROMOTION_WINDOW_HOURS } from '@/lib/constants'; -import { emitApiMetricsForResponse } from '@/lib/ai-gateway/o11y/api-metrics.server'; import { gatewayRateLimitKey, isGatewayAccountRateLimited, @@ -932,7 +931,7 @@ async function openRouterPost(request: NextRequest): Promise { @@ -17,9 +16,6 @@ jest.mock('next/server', () => { jest.mock('@/lib/user/server'); jest.mock('@/lib/organizations/organization-usage'); jest.mock('@/lib/autoTopUpInFlight'); -jest.mock('@/lib/ai-gateway/o11y/api-metrics.server', () => ({ - emitApiMetricsForResponse: jest.fn(), -})); jest.mock('@/lib/ai-gateway/is-free-model', () => ({ isFreeModel: jest.fn(), })); @@ -35,7 +31,6 @@ const mockedGetUserFromAuth = jest.mocked(getUserFromAuth); const mockedGetBalanceAndOrgSettings = jest.mocked(getBalanceAndOrgSettings); const mockedIsFreeModel = jest.mocked(isFreeModel); const mockedIsAutoTopUpInFlight = jest.mocked(isAutoTopUpInFlight); -const mockedEmitApiMetricsForResponse = jest.mocked(emitApiMetricsForResponse); const mockedFetch = jest.fn() as jest.MockedFunction; const originalFetch = globalThis.fetch; @@ -152,9 +147,6 @@ describe('POST /api/gateway/v1/audio/transcriptions', () => { expect(upstream.input_audio).toEqual({ data: 'UklGRiQA', format: 'wav' }); expect(upstream.safety_identifier).toBeTruthy(); expect(upstream.user).toBe(upstream.safety_identifier); - expect(mockedEmitApiMetricsForResponse.mock.calls[0]?.[0]).not.toMatchObject({ - feature: 'vscode-extension', - }); }); it('forwards organization provider policy through the OpenRouter provider field', async () => { diff --git a/apps/web/src/app/api/openrouter/audio/transcriptions/route.ts b/apps/web/src/app/api/openrouter/audio/transcriptions/route.ts index e890d18206..a256adc5e6 100644 --- a/apps/web/src/app/api/openrouter/audio/transcriptions/route.ts +++ b/apps/web/src/app/api/openrouter/audio/transcriptions/route.ts @@ -26,8 +26,6 @@ import type { OpenRouterProviderConfig } from '@/lib/ai-gateway/providers/openro import { ProxyErrorType } from '@/lib/proxy-error-types'; import { getBalanceAndOrgSettings } from '@/lib/organizations/organization-usage'; import { isFreeModel } from '@/lib/ai-gateway/is-free-model'; -import { emitApiMetricsForResponse } from '@/lib/ai-gateway/o11y/api-metrics.server'; -import { normalizeModelId } from '@/lib/ai-gateway/model-utils'; import { buildUpstreamBody, extractTranscriptionPromptInfo, @@ -343,25 +341,6 @@ export async function POST(request: NextRequest): Promise ({ - ...(jest.requireActual('next/server') as Record), - after: jest.fn(), -})); - -function chatRequest(overrides: Partial = {}): GatewayRequest { - return { - kind: 'chat_completions', - body: { - model: 'test-model', - messages: [], - ...overrides, - }, - }; -} - -function responsesRequest(overrides: Partial = {}): GatewayRequest { - return { - kind: 'responses', - body: { - model: 'test-model', - input: [], - ...overrides, - }, - }; -} - -function messagesRequest(overrides: Partial = {}): GatewayRequest { - return { - kind: 'messages', - body: { - model: 'test-model', - max_tokens: 16, - messages: [], - ...overrides, - }, - }; -} - -describe('getToolsAvailable', () => { - test('returns empty when tools is missing', () => { - expect(getToolsAvailable(chatRequest())).toEqual([]); - }); - - test('returns empty when tools is not an array', () => { - expect( - getToolsAvailable( - chatRequest({ - tools: { type: 'function', function: { name: 'search' } } as never, - }) - ) - ).toEqual([]); - expect(getToolsAvailable(chatRequest({ tools: 'search' as never }))).toEqual([]); - }); - - test('labels chat completion function and custom tools', () => { - expect( - getToolsAvailable( - chatRequest({ - tools: [ - null as never, - { type: 'function', function: { name: ' search ' } }, - { type: 'custom', custom: { name: 'browser' } }, - { type: 'function', function: { name: '' } }, - ], - }) - ) - ).toEqual(['function:search', 'custom:browser', 'function:unknown']); - }); - - test('labels responses tools and skips malformed entries', () => { - expect( - getToolsAvailable( - responsesRequest({ - tools: [ - null, - { type: 'function', name: 'lookup' }, - { type: 'custom', name: 'browser' }, - { type: 'mcp' }, - { type: 'web_search_preview' }, - {}, - { type: 123 }, - ] as GatewayResponsesRequest['tools'], - }) - ) - ).toEqual([ - 'function:lookup', - 'custom:browser', - 'mcp:unknown', - 'web_search_preview', - 'unknown:unknown', - 'unknown:unknown', - ]); - }); - - test('labels messages tools', () => { - expect( - getToolsAvailable( - messagesRequest({ - tools: [{ name: 'read_file', input_schema: { type: 'object' } }], - }) - ) - ).toEqual(['function:read_file']); - }); -}); - -describe('getToolsUsed', () => { - test('returns empty when chat messages are missing', () => { - expect(getToolsUsed(chatRequest({ messages: undefined as never }))).toEqual([]); - }); - - test('labels chat completion tool calls', () => { - expect( - getToolsUsed( - chatRequest({ - messages: [ - { - role: 'assistant', - content: null, - tool_calls: [ - { id: '1', type: 'function', function: { name: 'search', arguments: '{}' } }, - { id: '2', type: 'custom', custom: { name: 'browser', input: '{}' } }, - ], - }, - ], - }) - ) - ).toEqual(['function:search', 'custom:browser']); - }); - - test('ignores non-array chat completion tool_calls', () => { - expect( - getToolsUsed( - chatRequest({ - messages: [ - { - role: 'assistant', - content: null, - tool_calls: { id: '1', type: 'function' } as never, - }, - ], - }) - ) - ).toEqual([]); - }); - - test('tolerates responses tool calls with missing names', () => { - expect( - getToolsUsed( - responsesRequest({ - input: [ - { type: 'function_call', call_id: '1', name: 'search', arguments: '{}' }, - { type: 'function_call', call_id: '2' }, - { type: 'custom_tool_call', call_id: '3', name: 'browser', input: '{}' }, - { type: 'custom_tool_call', call_id: '4' }, - ] as GatewayResponsesRequest['input'], - }) - ) - ).toEqual(['function:search', 'function:unknown', 'custom:browser', 'custom:unknown']); - }); - - test('labels messages tool_use blocks', () => { - expect( - getToolsUsed( - messagesRequest({ - messages: [ - { - role: 'assistant', - content: [{ type: 'tool_use', id: '1', name: 'read_file', input: {} }], - }, - ], - }) - ) - ).toEqual(['function:read_file']); - }); -}); diff --git a/apps/web/src/lib/ai-gateway/o11y/api-metrics.server.ts b/apps/web/src/lib/ai-gateway/o11y/api-metrics.server.ts deleted file mode 100644 index fe0192db0a..0000000000 --- a/apps/web/src/lib/ai-gateway/o11y/api-metrics.server.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { after } from 'next/server'; -import { createParser, type EventSourceMessage } from 'eventsource-parser'; -import { z } from 'zod'; -import { O11Y_KILO_GATEWAY_CLIENT_SECRET, O11Y_SERVICE_URL } from '@/lib/config.server'; -import type { CompletionUsage } from 'openai/resources/completions'; -import type { GatewayRequest } from '@/lib/ai-gateway/providers/openrouter/types'; - -export type ApiMetricsTokens = { - inputTokens?: number; - outputTokens?: number; - cacheWriteTokens?: number; - cacheHitTokens?: number; - totalTokens?: number; -}; - -export type ApiMetricsParams = { - clientSecret: string; - kiloUserId: string; - organizationId?: string; - isAnonymous: boolean; - isStreaming: boolean; - userByok: boolean; - mode?: string; - provider: string; - inferenceProvider?: string; - requestedModel: string; - resolvedModel: string; - toolsAvailable: string[]; - toolsUsed: string[]; - ttfbMs: number; - completeRequestMs: number; - statusCode: number; - tokens?: ApiMetricsTokens; -}; - -export function getTokensFromCompletionUsage( - usage: CompletionUsage | null | undefined -): ApiMetricsTokens | undefined { - if (!usage) return undefined; - - const tokens: ApiMetricsTokens = { - inputTokens: usage.prompt_tokens, - outputTokens: usage.completion_tokens, - cacheHitTokens: usage.prompt_tokens_details?.cached_tokens, - totalTokens: usage.total_tokens, - // cacheWriteTokens isn't reported in OpenAI/OpenRouter usage. - cacheWriteTokens: undefined, - }; - - const hasAny = - tokens.inputTokens !== undefined || - tokens.outputTokens !== undefined || - tokens.cacheWriteTokens !== undefined || - tokens.cacheHitTokens !== undefined || - tokens.totalTokens !== undefined; - - return hasAny ? tokens : undefined; -} - -function trimString(value: unknown): string { - return typeof value === 'string' ? value.trim() : ''; -} - -function labeledTool(kind: string, name: unknown): string { - const trimmed = trimString(name); - return trimmed ? `${kind}:${trimmed}` : `${kind}:unknown`; -} - -function isObjectEntry(value: T): value is T & object { - return typeof value === 'object' && value !== null; -} - -export function getToolsAvailable(request: GatewayRequest): string[] { - if (!Array.isArray(request.body.tools)) return []; - - if (request.kind === 'responses') { - return request.body.tools.flatMap((tool): string[] => { - if (!isObjectEntry(tool)) return []; - if (tool.type === 'function') return [labeledTool('function', tool.name)]; - if (tool.type === 'custom') return [labeledTool('custom', tool.name)]; - if (tool.type === 'mcp') return [labeledTool('mcp', tool.server_label)]; - return typeof tool.type === 'string' && tool.type ? [tool.type] : ['unknown:unknown']; - }); - } - - if (request.kind === 'messages') { - return request.body.tools.flatMap((tool): string[] => - isObjectEntry(tool) ? [labeledTool('function', tool.name)] : [] - ); - } - - return request.body.tools.flatMap((tool): string[] => { - if (!isObjectEntry(tool)) return []; - if (tool.type === 'function') return [labeledTool('function', tool.function?.name)]; - if (tool.type === 'custom') return [labeledTool('custom', tool.custom?.name)]; - return ['unknown:unknown']; - }); -} - -export function getToolsUsed(request: GatewayRequest): string[] { - if (request.kind === 'responses') { - const { input } = request.body; - if (!Array.isArray(input)) return []; - - const used = new Array(); - - for (const item of input) { - if (!item || typeof item !== 'object') continue; - if (item.type === 'function_call') { - used.push(labeledTool('function', item.name)); - } else if (item.type === 'custom_tool_call') { - used.push(labeledTool('custom', item.name)); - } - } - - return used; - } - - if (request.kind === 'messages') { - const used = new Array(); - if (!Array.isArray(request.body.messages)) return used; - for (const message of request.body.messages) { - if (!message || typeof message !== 'object') continue; - if (message.role !== 'assistant') continue; - const content = Array.isArray(message.content) ? message.content : []; - for (const block of content) { - if (!block || typeof block !== 'object') continue; - if (block.type === 'tool_use') { - used.push(labeledTool('function', block.name)); - } - } - } - return used; - } - - if (!Array.isArray(request.body.messages)) return []; - - const used = new Array(); - - for (const message of request.body.messages) { - if (!message || typeof message !== 'object') continue; - if (message.role !== 'assistant') continue; - - if (!Array.isArray(message.tool_calls)) continue; - - for (const toolCall of message.tool_calls) { - if (!toolCall || typeof toolCall !== 'object') continue; - if (toolCall.type === 'function') { - used.push(labeledTool('function', toolCall.function?.name)); - continue; - } - - if (toolCall.type === 'custom') { - used.push(labeledTool('custom', toolCall.custom?.name)); - continue; - } - - used.push('unknown:unknown'); - } - } - - return used; -} - -const apiMetricsUrl = (() => { - if (!O11Y_SERVICE_URL) return null; - try { - return new URL('/ingest/api-metrics', O11Y_SERVICE_URL); - } catch { - return null; - } -})(); - -async function sendApiMetrics(params: ApiMetricsParams): Promise { - if (!apiMetricsUrl) return; - if (!O11Y_KILO_GATEWAY_CLIENT_SECRET) return; - - await fetch(apiMetricsUrl, { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'X-O11Y-ADMIN-TOKEN': O11Y_KILO_GATEWAY_CLIENT_SECRET || '', - }, - body: JSON.stringify(params), - }).catch(() => { - // Best-effort only; never fail the caller request. - }); -} - -export function emitApiMetrics(params: ApiMetricsParams) { - if (!apiMetricsUrl) return; - - after(async () => { - await sendApiMetrics(params); - }); -} - -export function emitApiMetricsForResponse( - params: Omit, - responseToDrain: Response, - requestStartedAt: number -) { - if (!apiMetricsUrl) return; - if (!O11Y_KILO_GATEWAY_CLIENT_SECRET) return; - - after(async () => { - let inferenceProvider: string | undefined; - try { - // Draining the body lets us measure the full upstream response time. - // Cap this so we don't keep background work running forever for SSE. - inferenceProvider = await drainResponseBodyForInferenceProvider(responseToDrain, 60_000); - } catch { - // Ignore body read errors; we still emit a timing. - } - - const completeRequestMs = Math.max(0, Math.round(performance.now() - requestStartedAt)); - - await sendApiMetrics({ - ...params, - inferenceProvider, - clientSecret: O11Y_KILO_GATEWAY_CLIENT_SECRET, - completeRequestMs, - } satisfies ApiMetricsParams); - }); -} - -async function drainResponseBodyForInferenceProvider( - response: Response, - timeoutMs: number -): Promise { - const body = response.body; - if (!body) return undefined; - - const reader = body.getReader(); - const contentType = response.headers.get('content-type') ?? ''; - const isEventStream = contentType.includes('text/event-stream'); - try { - const startedAt = performance.now(); - const decoder = new TextDecoder(); - let inferenceProvider: string | undefined; - - const sseParser = isEventStream - ? createParser({ - onEvent(event: EventSourceMessage) { - if (event.data === '[DONE]') return; - const json = safeParseJson(event.data); - if (!json) return; - inferenceProvider = extractInferenceProvider(json); - }, - }) - : null; - - let buffered = ''; - const MAX_BUFFER_CHARS = 512_000; - - while (true) { - const elapsedMs = performance.now() - startedAt; - const remainingMs = timeoutMs - elapsedMs; - if (remainingMs <= 0) { - try { - await reader.cancel(); - } catch { - /** intentionally empty */ - } - return inferenceProvider; - } - - const result = await Promise.race([ - reader.read(), - sleep(remainingMs).then(() => ({ timeout: true as const })), - ]); - - if ('timeout' in result) { - try { - await reader.cancel(); - } catch { - /** intentionally empty */ - } - return inferenceProvider; - } - - if (result.done) { - if (!inferenceProvider && !isEventStream && buffered) { - const json = safeParseJson(buffered); - inferenceProvider = json ? extractInferenceProvider(json) : undefined; - } - return inferenceProvider; - } - - if (result.value) { - const chunk = decoder.decode(result.value, { stream: true }); - if (sseParser) { - sseParser.feed(chunk); - } else if (buffered.length < MAX_BUFFER_CHARS) { - buffered += chunk; - } - } - } - } finally { - reader.releaseLock(); - } -} - -const inferenceProviderSchema = z.object({ - provider: z.string().min(1).optional(), - choices: z - .array( - z.object({ - message: z - .object({ - provider_metadata: z - .object({ - gateway: z - .object({ - routing: z.object({ - finalProvider: z.string().min(1).optional(), - }), - }) - .partial() - .optional(), - }) - .partial() - .optional(), - }) - .partial() - .optional(), - delta: z - .object({ - provider_metadata: z - .object({ - gateway: z - .object({ - routing: z.object({ - finalProvider: z.string().min(1).optional(), - }), - }) - .partial() - .optional(), - }) - .partial() - .optional(), - }) - .partial() - .optional(), - }) - ) - .optional(), -}); - -function extractInferenceProvider(data: unknown): string | undefined { - const parsed = inferenceProviderSchema.safeParse(data); - if (!parsed.success) return undefined; - - const directProvider = parsed.data.provider?.trim(); - if (directProvider) return directProvider; - - const choice = parsed.data.choices?.[0]; - const finalProvider = - choice?.message?.provider_metadata?.gateway?.routing?.finalProvider?.trim() ?? - choice?.delta?.provider_metadata?.gateway?.routing?.finalProvider?.trim(); - return finalProvider || undefined; -} - -function safeParseJson(payload: string): unknown { - if (!payload) return null; - try { - return JSON.parse(payload) as unknown; - } catch { - return null; - } -} - -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} diff --git a/apps/web/src/lib/ai-gateway/providers/upstream-attempt.ts b/apps/web/src/lib/ai-gateway/providers/upstream-attempt.ts index 459d321275..3e5dde1454 100644 --- a/apps/web/src/lib/ai-gateway/providers/upstream-attempt.ts +++ b/apps/web/src/lib/ai-gateway/providers/upstream-attempt.ts @@ -1,7 +1,6 @@ import type { NextResponse } from 'next/server'; import { buildExperimentPromptCapture } from '@/lib/ai-gateway/experiments/persist'; -import { getToolsAvailable, getToolsUsed } from '@/lib/ai-gateway/o11y/api-metrics.server'; import type { ExperimentPromptCapture } from '@/lib/ai-gateway/processUsage.types'; import { applyProviderSpecificLogic } from '@/lib/ai-gateway/providers/apply-provider-specific-logic'; import type { GetProviderProviderResult } from '@/lib/ai-gateway/providers/get-provider'; @@ -32,8 +31,6 @@ type SendUpstreamAttemptResult = | { type: 'success'; response: Response; - toolsAvailable: string[]; - toolsUsed: string[]; experimentPromptCapture?: ExperimentPromptCapture; }; @@ -93,8 +90,6 @@ export async function sendUpstreamAttempt({ return { type: 'success', response: result.response, - toolsAvailable: getToolsAvailable(request), - toolsUsed: getToolsUsed(request), experimentPromptCapture, }; } diff --git a/apps/web/src/lib/security-agent/services/api-metrics.server.ts b/apps/web/src/lib/security-agent/services/api-metrics.server.ts new file mode 100644 index 0000000000..42c084f4ae --- /dev/null +++ b/apps/web/src/lib/security-agent/services/api-metrics.server.ts @@ -0,0 +1,62 @@ +import { after } from 'next/server'; +import { O11Y_KILO_GATEWAY_CLIENT_SECRET, O11Y_SERVICE_URL } from '@/lib/config.server'; + +type ApiMetricsTokens = { + inputTokens?: number; + outputTokens?: number; + cacheWriteTokens?: number; + cacheHitTokens?: number; + totalTokens?: number; +}; + +type ApiMetricsParams = { + clientSecret: string; + kiloUserId: string; + organizationId?: string; + isAnonymous: boolean; + isStreaming: boolean; + userByok: boolean; + mode?: string; + provider: string; + inferenceProvider?: string; + requestedModel: string; + resolvedModel: string; + toolsAvailable: string[]; + toolsUsed: string[]; + ttfbMs: number; + completeRequestMs: number; + statusCode: number; + tokens?: ApiMetricsTokens; +}; + +const apiMetricsUrl = (() => { + if (!O11Y_SERVICE_URL) return null; + try { + return new URL('/ingest/api-metrics', O11Y_SERVICE_URL); + } catch { + return null; + } +})(); + +async function sendApiMetrics(params: ApiMetricsParams): Promise { + if (!apiMetricsUrl || !O11Y_KILO_GATEWAY_CLIENT_SECRET) return; + + await fetch(apiMetricsUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'X-O11Y-ADMIN-TOKEN': O11Y_KILO_GATEWAY_CLIENT_SECRET, + }, + body: JSON.stringify(params), + }).catch(() => { + // Best-effort only; never fail the caller request. + }); +} + +export function emitApiMetrics(params: ApiMetricsParams) { + if (!apiMetricsUrl) return; + + after(async () => { + await sendApiMetrics(params); + }); +} diff --git a/apps/web/src/lib/security-agent/services/extraction-service.ts b/apps/web/src/lib/security-agent/services/extraction-service.ts index 54ddd9518e..61075f420c 100644 --- a/apps/web/src/lib/security-agent/services/extraction-service.ts +++ b/apps/web/src/lib/security-agent/services/extraction-service.ts @@ -15,9 +15,9 @@ import type { SecurityFinding } from '@kilocode/db/schema'; import type { SecurityFindingSandboxAnalysis, SandboxSuggestedAction } from '../core/types'; import { addBreadcrumb, captureException, startSpan } from '@sentry/nextjs'; import { sentryLogger } from '@/lib/utils.server'; -import { emitApiMetrics } from '@/lib/ai-gateway/o11y/api-metrics.server'; import { O11Y_KILO_GATEWAY_CLIENT_SECRET } from '@/lib/config.server'; import { DEFAULT_SECURITY_AGENT_ANALYSIS_MODEL } from '../core/constants'; +import { emitApiMetrics } from './api-metrics.server'; const VALID_SUGGESTED_ACTIONS: SandboxSuggestedAction[] = [ 'dismiss', diff --git a/apps/web/src/lib/security-agent/services/triage-service.ts b/apps/web/src/lib/security-agent/services/triage-service.ts index 6b22954c3c..4dbf912ac9 100644 --- a/apps/web/src/lib/security-agent/services/triage-service.ts +++ b/apps/web/src/lib/security-agent/services/triage-service.ts @@ -14,9 +14,9 @@ import type { SecurityFinding } from '@kilocode/db/schema'; import type { SecurityFindingTriage } from '../core/types'; import { addBreadcrumb, captureException, startSpan } from '@sentry/nextjs'; import { logExceptInTest, sentryLogger } from '@/lib/utils.server'; -import { emitApiMetrics } from '@/lib/ai-gateway/o11y/api-metrics.server'; import { O11Y_KILO_GATEWAY_CLIENT_SECRET } from '@/lib/config.server'; import { DEFAULT_SECURITY_AGENT_TRIAGE_MODEL } from '../core/constants'; +import { emitApiMetrics } from './api-metrics.server'; const log = sentryLogger('security-agent:triage', 'info'); const logError = sentryLogger('security-agent:triage', 'error');