From 5e5447a2ba0f898daa6bba6340d7cd54cf9b7e17 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:50:29 -0300 Subject: [PATCH] fix(sse): count gate/combo-rejected requests in per-api-key usage (#6698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requests rejected before handleChatCore — a pipeline-gate rejection (provider circuit breaker OPEN / model cooldown) or a combo whose targets were all exhausted — short-circuited in chat.ts and only wrote a call_logs row (dashboard/logs). They never reached persistFailureUsage, so no usage_history row was created and the per-api-key usage counter (getApiKeyUsageRows reads usage_history) never incremented. An API key whose traffic was entirely gate/breaker-rejected showed zero requests despite real usage. Route both rejection paths through recordRejectedRequestUsage(), which writes the call_logs row (unchanged visibility) AND a usage_history row attributed to the api key with success:false, mirroring persistFailureUsage. Regression guard: tests/unit/rejected-request-usage.test.ts. --- src/sse/handlers/chat.ts | 42 +++++----- src/sse/handlers/rejectedRequestUsage.ts | 98 +++++++++++++++++++++++ tests/unit/rejected-request-usage.test.ts | 84 +++++++++++++++++++ 3 files changed, 202 insertions(+), 22 deletions(-) create mode 100644 src/sse/handlers/rejectedRequestUsage.ts create mode 100644 tests/unit/rejected-request-usage.test.ts diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index caf3555c09..8f67fc95a0 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -889,27 +889,25 @@ export async function handleChat( // Record telemetry recordTelemetry(telemetry); - // Log combo failures that bypassed handleChatCore (e.g. all targets skipped by circuit breaker) + // Log combo failures that bypassed handleChatCore (e.g. all targets skipped by circuit breaker). + // Records BOTH a call_logs row (dashboard/logs) AND a usage_history row attributed to the api key + // (success:false) so gate/breaker-rejected traffic is counted per key — support-mesh 2026-07-08. if (!response.ok) { try { - const { saveCallLog } = await import("@/lib/usageDb"); - saveCallLog({ - id: undefined, - method: "POST", - path: clientRawRequest?.endpoint || "/v1/chat/completions", + const { recordRejectedRequestUsage } = await import("./rejectedRequestUsage"); + await recordRejectedRequestUsage({ status: response.status, model: body?.model || resolvedModelStr, requestedModel: body?.model || resolvedModelStr, provider: "-", - connectionId: undefined, - duration: Date.now() - (telemetry?.startTime || Date.now()), - tokens: {}, + endpoint: clientRawRequest?.endpoint, error: `[${response.status}] Combo "${combo.name}" failed — all targets exhausted`, comboName: combo.name, - comboStepId: null, - comboExecutionKey: null, + apiKeyId: apiKeyInfo?.id ?? null, + apiKeyName: apiKeyInfo?.name ?? null, correlationId: reqId, - }).catch(() => {}); + startTime: telemetry?.startTime, + }); } catch {} } return withCorrelationId(withSessionHeader(response, sessionId), reqId); @@ -1113,26 +1111,26 @@ async function handleSingleModelChat( ...(bypassReason ? { bypassReason } : {}), }); if (gate) { - // Log the rejected request so it appears in /dashboard/logs + // Log the rejected request so it appears in /dashboard/logs AND is counted in the + // per-api-key usage analytics (usage_history, success:false) — otherwise a key whose + // traffic is entirely gate/breaker-rejected shows "zero requests" (support-mesh 2026-07-08). try { - const { saveCallLog } = await import("@/lib/usageDb"); - saveCallLog({ - id: undefined, - method: "POST", - path: clientRawRequest?.endpoint || "/v1/chat/completions", + const { recordRejectedRequestUsage } = await import("./rejectedRequestUsage"); + await recordRejectedRequestUsage({ status: gate.status, model, requestedModel: body?.model || modelStr, provider, - connectionId: undefined, - duration: Date.now() - (telemetry?.startTime || Date.now()), - tokens: {}, + endpoint: clientRawRequest?.endpoint, error: `[${gate.status}] Pipeline gate rejected`, comboName: isCombo ? comboName : null, comboStepId: isCombo ? (runtimeOptions?.comboStepId ?? null) : null, comboExecutionKey: isCombo ? (runtimeOptions?.comboExecutionKey ?? null) : null, + apiKeyId: apiKeyInfo?.id ?? null, + apiKeyName: apiKeyInfo?.name ?? null, correlationId: runtimeOptions?.correlationId ?? null, - }).catch(() => {}); + startTime: telemetry?.startTime, + }); } catch {} return gate; } diff --git a/src/sse/handlers/rejectedRequestUsage.ts b/src/sse/handlers/rejectedRequestUsage.ts new file mode 100644 index 0000000000..7626e83e81 --- /dev/null +++ b/src/sse/handlers/rejectedRequestUsage.ts @@ -0,0 +1,98 @@ +/** + * Records a request that was rejected BEFORE reaching handleChatCore — i.e. a + * pipeline-gate rejection (provider circuit breaker OPEN / model cooldown) or a + * combo whose targets were all exhausted. These paths short-circuit in + * `chat.ts` and used to write only a `call_logs` row via `saveCallLog`, which + * kept them visible in /dashboard/logs but left them absent from `usage_history` + * — the table `getApiKeyUsageRows` reads. The effect was an API key whose + * traffic was entirely gate-rejected showing "zero requests" despite real + * usage (support-mesh escalation, 2026-07-08). + * + * This helper writes BOTH: + * 1. the `call_logs` row (unchanged dashboard/logs visibility), and + * 2. a `usage_history` row attributed to the api key with `success: false`, + * mirroring `persistFailureUsage` in the post-executor failure path, + * so rejected traffic is counted per key just like executor-level failures. + * + * Best-effort: both writes swallow their own errors — logging a rejection must + * never turn into a second failure on the response path. + */ +import { saveCallLog, saveRequestUsage } from "@/lib/usageDb"; + +export interface RejectedRequestUsageInput { + status: number; + model: string; + requestedModel?: string; + provider: string; + endpoint?: string | null; + error?: string | null; + comboName?: string | null; + comboStepId?: string | null; + comboExecutionKey?: string | null; + correlationId?: string | null; + apiKeyId?: string | null; + apiKeyName?: string | null; + connectionId?: string | null; + /** When the request started, for the duration/latency columns. */ + startTime?: number; +} + +export async function recordRejectedRequestUsage(input: RejectedRequestUsageInput): Promise { + const { + status, + model, + requestedModel, + provider, + endpoint, + error, + comboName = null, + comboStepId = null, + comboExecutionKey = null, + correlationId = null, + apiKeyId = null, + apiKeyName = null, + connectionId = undefined, + startTime, + } = input; + + const now = Date.now(); + const duration = typeof startTime === "number" ? now - startTime : 0; + + // 1. call_logs — preserves /dashboard/logs visibility (unchanged behavior). + saveCallLog({ + id: undefined, + method: "POST", + path: endpoint || "/v1/chat/completions", + status, + model, + requestedModel: requestedModel || model, + provider, + connectionId, + duration, + tokens: {}, + error: error || null, + comboName, + comboStepId, + comboExecutionKey, + apiKeyId, + apiKeyName, + correlationId, + }).catch(() => {}); + + // 2. usage_history — so the per-api-key usage counter reflects rejected + // traffic (success:false), matching persistFailureUsage semantics. + await saveRequestUsage({ + provider, + model, + connectionId: connectionId ?? null, + apiKeyId, + apiKeyName, + tokens: {}, + serviceTier: "standard", + status: String(status), + success: false, + latencyMs: duration, + comboStrategy: comboName || null, + endpoint: endpoint || "/v1/chat/completions", + }).catch(() => {}); +} diff --git a/tests/unit/rejected-request-usage.test.ts b/tests/unit/rejected-request-usage.test.ts new file mode 100644 index 0000000000..bec7fc223e --- /dev/null +++ b/tests/unit/rejected-request-usage.test.ts @@ -0,0 +1,84 @@ +// Regression guard — support-mesh escalation (2026-07-08, whatsbrasil): +// an OmniRoute API key ("opencode-mac") showed "zero requisições" even though +// it received traffic. Root cause: requests rejected *before* handleChatCore +// (pipeline-gate / provider circuit breaker OPEN, or a combo with every target +// exhausted) short-circuit in src/sse/handlers/chat.ts and only wrote a +// call_logs row via saveCallLog — they never reached persistFailureUsage, so +// no usage_history row was created and the per-api-key usage counter +// (getApiKeyUsageRows, which reads usage_history) never incremented. +// +// The fix routes those rejections through recordRejectedRequestUsage(), which +// writes BOTH the call_logs row (dashboard/logs visibility, preserved) AND a +// usage_history row attributed to the api key with success:false — so the +// rejected traffic is counted per key. + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-rejected-usage-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); +const callLogs = await import("../../src/lib/usage/callLogs.ts"); +const { recordRejectedRequestUsage } = await import("../../src/sse/handlers/rejectedRequestUsage.ts"); + +test.beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + usageHistory.clearPendingRequests(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("gate-rejected request is attributed to the api key in usage_history", async () => { + await recordRejectedRequestUsage({ + status: 503, + model: "claude-sonnet-5", + requestedModel: "claude-sonnet-5", + provider: "anthropic", + endpoint: "/v1/chat/completions", + error: "[503] Pipeline gate rejected", + apiKeyId: "key-opencode-mac", + apiKeyName: "opencode-mac", + startTime: Date.now() - 5, + }); + + // usage_history row exists, attributed to the key, marked as a failure. + const rows = (await usageHistory.getUsageDb()).data.history; + const keyRows = rows.filter((r: { apiKeyId?: string | null }) => r.apiKeyId === "key-opencode-mac"); + assert.equal(keyRows.length, 1, "expected one usage_history row for the rejected request"); + assert.equal(keyRows[0].success, false, "rejected request must be recorded as success:false"); + + // call_logs visibility is preserved (dashboard/logs). + const logs = await callLogs.getCallLogs({}); + const rejected = (logs.logs ?? logs).filter?.((l: { apiKeyName?: string | null }) => l.apiKeyName === "opencode-mac"); + assert.ok(rejected && rejected.length >= 1, "expected a call_logs row for the rejected request"); +}); + +test("combo-exhausted rejection is also counted per api key", async () => { + await recordRejectedRequestUsage({ + status: 502, + model: "gpt-5", + requestedModel: "gpt-5", + provider: "-", + endpoint: "/v1/chat/completions", + error: '[502] Combo "prod" failed — all targets exhausted', + comboName: "prod", + apiKeyId: "key-opencode-mac", + apiKeyName: "opencode-mac", + startTime: Date.now() - 3, + }); + + const rows = (await usageHistory.getUsageDb()).data.history; + const keyRows = rows.filter((r: { apiKeyId?: string | null }) => r.apiKeyId === "key-opencode-mac"); + assert.equal(keyRows.length, 1); + assert.equal(keyRows[0].success, false); +});