mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-10 17:22:17 +03:00
Compare commits
4 Commits
fix/9927-e
...
feat/9620-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c90c929bf | ||
|
|
fdc59323bd | ||
|
|
9612b0d909 | ||
|
|
e6a4dfc72b |
2
changelog.d/features/9620-cache-read-write-logs.md
Normal file
2
changelog.d/features/9620-cache-read-write-logs.md
Normal file
@@ -0,0 +1,2 @@
|
||||
- Show cache-read and cache-write token counts in request log rows and details when providers
|
||||
report them.
|
||||
@@ -1 +0,0 @@
|
||||
- fix(encryption): name failing credential + recovery path in decrypt errors, dedupe per connection (#9927)
|
||||
@@ -14,35 +14,17 @@ import { buildErrorBody } from "@omniroute/open-sse/utils/error";
|
||||
* Returns a 424 (Failed Dependency) response with a clear, sanitized message
|
||||
* when the connection carries that flag; otherwise null (proceed normally).
|
||||
*/
|
||||
const STALE_ENCRYPTION_MESSAGE =
|
||||
"Stored API key cannot be decrypted (STORAGE_ENCRYPTION_KEY changed or unset). Re-enter the API key.";
|
||||
|
||||
export function buildStaleEncryptionKeyResponse(
|
||||
connection:
|
||||
| {
|
||||
credentialDecryptFailed?: unknown;
|
||||
id?: unknown;
|
||||
provider?: unknown;
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
connection: { credentialDecryptFailed?: unknown } | null | undefined
|
||||
): NextResponse | null {
|
||||
if (!connection || connection.credentialDecryptFailed !== true) return null;
|
||||
|
||||
// #9927 — surface WHICH credential failed plus the recovery path so the
|
||||
// dashboard points the operator at the account to re-authenticate instead of
|
||||
// a generic "API key cannot be decrypted".
|
||||
const provider = typeof connection.provider === "string" ? connection.provider : "";
|
||||
const id = typeof connection.id === "string" ? connection.id : "";
|
||||
const identity = [provider && `provider "${provider}"`, id && `connection ${id}`]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
const message =
|
||||
`Stored credential${identity ? ` for ${identity}` : ""} cannot be decrypted ` +
|
||||
`(STORAGE_ENCRYPTION_KEY changed or unset). Re-authenticate this account, or verify ` +
|
||||
`STORAGE_ENCRYPTION_KEY matches the key used to store it.`;
|
||||
|
||||
// buildErrorBody sanitizes the message (Rule #12); override the type so the
|
||||
// client can key off the specific stale-encryption cause.
|
||||
const body = buildErrorBody(424, message);
|
||||
const body = buildErrorBody(424, STALE_ENCRYPTION_MESSAGE);
|
||||
body.error.type = "storage_encryption_stale";
|
||||
return NextResponse.json(body, { status: 424 });
|
||||
}
|
||||
|
||||
@@ -51,31 +51,6 @@ export interface ConnectionFields {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* #9927 — dedupe tracker for credential-decrypt-failure messages. The health
|
||||
* sweep / refresh / request routing re-decrypt the same corrupt row every
|
||||
* cycle; we log the enriched, actionable message ONCE per
|
||||
* (provider + connection + failing-ciphertext) state so it does not spam
|
||||
* every sweep, while still re-logging if the row state actually changes
|
||||
* (e.g. a different field starts failing) instead of permanently suppressing.
|
||||
*/
|
||||
const loggedDecryptFailures = new Set<string>();
|
||||
|
||||
function decryptFailureSignature(
|
||||
connectionId: string,
|
||||
provider: string,
|
||||
failed: Array<{ field: string; value: unknown }>
|
||||
): string {
|
||||
const parts = failed
|
||||
.map((f) => `${f.field}:${typeof f.value === "string" ? f.value : ""}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
return `${provider}::${connectionId}::${parts}`;
|
||||
}
|
||||
|
||||
const RECOVERY_HINT =
|
||||
"Re-authenticate this account, or verify STORAGE_ENCRYPTION_KEY matches the key used to store it.";
|
||||
|
||||
/**
|
||||
* Derive the PRIMARY encryption key using the static salt.
|
||||
* This is the canonical key derivation that all new encryptions use.
|
||||
@@ -182,10 +157,7 @@ export function encrypt(plaintext: string | null | undefined): string | null | u
|
||||
* auto-migration: the next encrypt() call will re-encrypt it with the
|
||||
* static-salt key, gradually migrating the database.
|
||||
*/
|
||||
export function decrypt(
|
||||
ciphertext: string | null | undefined,
|
||||
opts?: { quiet?: boolean }
|
||||
): string | null | undefined {
|
||||
export function decrypt(ciphertext: string | null | undefined): string | null | undefined {
|
||||
if (!ciphertext || typeof ciphertext !== "string") return ciphertext;
|
||||
|
||||
// Not encrypted — return as-is (legacy plaintext or passthrough mode)
|
||||
@@ -232,21 +204,14 @@ export function decrypt(
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// #9927 — the low-level generic log is suppressed when called through the
|
||||
// connection-decryption path (quiet:true); decryptConnectionFields emits a
|
||||
// single enriched message naming the credential + recovery path instead.
|
||||
if (!opts?.quiet) {
|
||||
console.error(
|
||||
`[Encryption] Decryption failed. Ciphertext prefix: ${ciphertext.slice(0, 30)}... ` +
|
||||
`Auth tag validation likely failed.`
|
||||
);
|
||||
}
|
||||
console.error(
|
||||
`[Encryption] Decryption failed. Ciphertext prefix: ${ciphertext.slice(0, 30)}... ` +
|
||||
`Auth tag validation likely failed.`
|
||||
);
|
||||
return null;
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (!opts?.quiet) {
|
||||
console.error("[Encryption] Decryption failed:", message);
|
||||
}
|
||||
console.error("[Encryption] Decryption failed:", message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -277,13 +242,10 @@ export function decryptConnectionFields<T extends ConnectionFields | null | unde
|
||||
if (!row) return row;
|
||||
if (!isEncryptionEnabled()) return row;
|
||||
|
||||
// quiet:true — the low-level generic decrypt() log is suppressed here so a
|
||||
// single failure emits ONE enriched message (below) naming the credential
|
||||
// and recovery path (#9927) instead of one generic line per field per cycle.
|
||||
const apiKey = decrypt(row.apiKey, { quiet: true });
|
||||
const accessToken = decrypt(row.accessToken, { quiet: true });
|
||||
const refreshToken = decrypt(row.refreshToken, { quiet: true });
|
||||
const idToken = decrypt(row.idToken, { quiet: true });
|
||||
const apiKey = decrypt(row.apiKey);
|
||||
const accessToken = decrypt(row.accessToken);
|
||||
const refreshToken = decrypt(row.refreshToken);
|
||||
const idToken = decrypt(row.idToken);
|
||||
|
||||
// #6148 — a stored credential that is still encrypted (`enc:v1:…`) but
|
||||
// decrypts to null means the STORAGE_ENCRYPTION_KEY changed or was unset.
|
||||
@@ -295,31 +257,6 @@ export function decryptConnectionFields<T extends ConnectionFields | null | unde
|
||||
(looksEncrypted(row.refreshToken) && refreshToken === null) ||
|
||||
(looksEncrypted(row.idToken) && idToken === null);
|
||||
|
||||
if (credentialDecryptFailed) {
|
||||
const failed: Array<{ field: string; value: unknown }> = [];
|
||||
if (looksEncrypted(row.apiKey) && apiKey === null) failed.push({ field: "apiKey", value: row.apiKey });
|
||||
if (looksEncrypted(row.accessToken) && accessToken === null)
|
||||
failed.push({ field: "accessToken", value: row.accessToken });
|
||||
if (looksEncrypted(row.refreshToken) && refreshToken === null)
|
||||
failed.push({ field: "refreshToken", value: row.refreshToken });
|
||||
if (looksEncrypted(row.idToken) && idToken === null) failed.push({ field: "idToken", value: row.idToken });
|
||||
|
||||
const connectionId = typeof row.id === "string" ? row.id : "";
|
||||
const provider = typeof row.provider === "string" ? row.provider : "unknown";
|
||||
const fields = failed.map((f) => f.field).join(", ");
|
||||
|
||||
// Dedupe per credential/row state: the sweep re-decrypts the same corrupt
|
||||
// row every cycle — log ONCE unless the failing state actually changes.
|
||||
const signature = decryptFailureSignature(connectionId, provider, failed);
|
||||
if (!loggedDecryptFailures.has(signature)) {
|
||||
loggedDecryptFailures.add(signature);
|
||||
console.error(
|
||||
`[Encryption] Failed to decrypt credential(s) [${fields}] for provider ` +
|
||||
`"${provider}" (connection ${connectionId || "unknown"}). ${RECOVERY_HINT}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
apiKey,
|
||||
|
||||
@@ -95,6 +95,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
(props, ref) => {
|
||||
const { initialSelectedId } = props as any;
|
||||
const t = useTranslations("requestLogger");
|
||||
const tCache = useTranslations("cache");
|
||||
const { emailsVisible } = useEmailPrivacyStore();
|
||||
|
||||
// Get translated status filters
|
||||
@@ -1514,6 +1515,30 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
<span className="text-emerald-700 dark:text-emerald-400">
|
||||
{log.tokens?.out?.toLocaleString() || 0}
|
||||
</span>
|
||||
{log.tokens?.cacheRead != null && log.tokens.cacheRead > 0 && (
|
||||
<>
|
||||
<span className="mx-1 text-border">|</span>
|
||||
<span className="text-text-muted">CR:</span>{" "}
|
||||
<span
|
||||
className="text-sky-700 dark:text-sky-400"
|
||||
title={tCache("cachedTokensCol")}
|
||||
>
|
||||
{log.tokens.cacheRead.toLocaleString()}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{log.tokens?.cacheWrite != null && log.tokens.cacheWrite > 0 && (
|
||||
<>
|
||||
<span className="mx-1 text-border">|</span>
|
||||
<span className="text-text-muted">CW:</span>{" "}
|
||||
<span
|
||||
className="text-amber-700 dark:text-amber-400"
|
||||
title={tCache("cacheCreation")}
|
||||
>
|
||||
{log.tokens.cacheWrite.toLocaleString()}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{log.tokens?.compressed != null && log.tokens.compressed > 0 && (
|
||||
<>
|
||||
<span className="mx-1 text-border">|</span>
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
// #9927 — A credential that no longer decrypts (e.g. STORAGE_ENCRYPTION_KEY
|
||||
// changed between restarts) must emit a single, enriched error naming the
|
||||
// provider + connection id + failing field(s) and a recovery path, instead of
|
||||
// the generic low-level `[Encryption] Decryption failed … Auth tag validation
|
||||
// likely failed` line that carries no identity and is re-printed every sweep.
|
||||
|
||||
const ORIGINAL_STORAGE_KEY = process.env.STORAGE_ENCRYPTION_KEY;
|
||||
|
||||
// Cache-busted fresh import so the encryption module re-derives its key from
|
||||
// the current STORAGE_ENCRYPTION_KEY and resets module-level dedupe state.
|
||||
async function importFresh(modulePath: string) {
|
||||
const url = pathToFileURL(path.resolve(modulePath)).href;
|
||||
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
if (ORIGINAL_STORAGE_KEY === undefined) {
|
||||
delete process.env.STORAGE_ENCRYPTION_KEY;
|
||||
} else {
|
||||
process.env.STORAGE_ENCRYPTION_KEY = ORIGINAL_STORAGE_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
function captureConsoleError(fn: () => void): string[] {
|
||||
const original = console.error;
|
||||
const logs: string[] = [];
|
||||
console.error = (...args: unknown[]) => {
|
||||
logs.push(args.join(" "));
|
||||
};
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
console.error = original;
|
||||
}
|
||||
return logs;
|
||||
}
|
||||
|
||||
test("decryptConnectionFields logs failed credential identity + recovery path (#9927)", async () => {
|
||||
// 1. Encrypt an apiKey under key A.
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "stale-key-9927-A";
|
||||
const encA = await importFresh("src/lib/db/encryption.ts");
|
||||
const ciphertext = encA.encrypt("sk-real-secret-key");
|
||||
assert.match(ciphertext, /^enc:v1:/, "expected a real enc:v1 ciphertext");
|
||||
|
||||
// 2. Read it back under a DIFFERENT key B (simulating a changed key).
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "stale-key-9927-B";
|
||||
const encB = await importFresh("src/lib/db/encryption.ts");
|
||||
|
||||
const logs = captureConsoleError(() => {
|
||||
encB.decryptConnectionFields({
|
||||
id: "conn-9927",
|
||||
provider: "openai",
|
||||
apiKey: ciphertext,
|
||||
});
|
||||
});
|
||||
|
||||
// Must flag the failure so callers can surface the cause.
|
||||
const decrypted = encB.decryptConnectionFields({
|
||||
id: "conn-9927",
|
||||
provider: "openai",
|
||||
apiKey: ciphertext,
|
||||
});
|
||||
assert.equal(decrypted.credentialDecryptFailed, true);
|
||||
|
||||
// The generic low-level log must NOT fire (quiet:true); instead ONE enriched
|
||||
// message names provider + connection id + recovery path.
|
||||
assert.equal(
|
||||
logs.some((l) => /Auth tag validation likely failed/.test(l)),
|
||||
false,
|
||||
"generic low-level decrypt log must be suppressed on the connection path"
|
||||
);
|
||||
|
||||
const enriched = logs.find((l) => l.includes("Failed to decrypt credential(s)"));
|
||||
assert.ok(enriched, "expected an enriched credential-decrypt-failure log");
|
||||
assert.match(enriched, /provider "openai"/, "log must name the provider");
|
||||
assert.match(enriched, /conn-9927/, "log must name the connection id");
|
||||
assert.match(enriched, /apiKey/, "log must name the failing field");
|
||||
assert.match(
|
||||
enriched,
|
||||
/STORAGE_ENCRYPTION_KEY matches the key used to store it/,
|
||||
"log must include the recovery path"
|
||||
);
|
||||
});
|
||||
|
||||
test("credential-decrypt failure is logged once per connection (dedupe #9927)", async () => {
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "stale-key-9927-dedupe-A";
|
||||
const encA = await importFresh("src/lib/db/encryption.ts");
|
||||
const ciphertext = encA.encrypt("sk-dedupe-key");
|
||||
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "stale-key-9927-dedupe-B";
|
||||
const encB = await importFresh("src/lib/db/encryption.ts");
|
||||
|
||||
const row = { id: "conn-dedupe", provider: "openai", apiKey: ciphertext };
|
||||
const logs = captureConsoleError(() => {
|
||||
// Simulate the health sweep re-decrypting the same corrupt row repeatedly.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
encB.decryptConnectionFields(row);
|
||||
}
|
||||
});
|
||||
|
||||
const enriched = logs.filter((l) => l.includes("Failed to decrypt credential(s)"));
|
||||
assert.equal(enriched.length, 1, "identical failure must be logged once per connection");
|
||||
});
|
||||
158
tests/unit/ui/request-logger-cache-tokens.test.tsx
Normal file
158
tests/unit/ui/request-logger-cache-tokens.test.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: (namespace?: string) => (key: string) =>
|
||||
namespace === "cache"
|
||||
? ({ cachedTokensCol: "Cache Read", cacheCreation: "Cache Write" }[key] ?? key)
|
||||
: key,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/store/emailPrivacyStore", () => ({
|
||||
default: () => ({ emailsVisible: true }),
|
||||
}));
|
||||
|
||||
const RequestLoggerV2 = (await import("@/shared/components/RequestLoggerV2")).default;
|
||||
const RequestLoggerDetail = (await import("@/shared/components/RequestLoggerDetail")).default;
|
||||
|
||||
let container: HTMLElement;
|
||||
let root: Root;
|
||||
|
||||
const populatedLog = {
|
||||
id: "log-cache",
|
||||
status: 200,
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
model: "gpt-cache",
|
||||
provider: "openai",
|
||||
timestamp: "2026-08-10T12:00:00.000Z",
|
||||
duration: 1_000,
|
||||
tokens: {
|
||||
in: 1_000,
|
||||
out: 250,
|
||||
cacheRead: 800,
|
||||
cacheWrite: 120,
|
||||
reasoning: 50,
|
||||
compressed: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
async function render(component: React.ReactNode) {
|
||||
await act(async () => {
|
||||
root.render(component);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("request log cache token metrics (#9620)", () => {
|
||||
it("renders cache read/write beside the existing row token metrics", async () => {
|
||||
const emptyCacheLog = {
|
||||
...populatedLog,
|
||||
id: "log-no-cache",
|
||||
model: "gpt-no-cache",
|
||||
timestamp: "2026-08-10T11:59:00.000Z",
|
||||
tokens: { ...populatedLog.tokens, cacheRead: null, cacheWrite: 0 },
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith("/api/usage/call-logs")) {
|
||||
return Response.json([populatedLog, emptyCacheLog]);
|
||||
}
|
||||
if (url.startsWith("/api/provider-nodes")) return Response.json({ nodes: [] });
|
||||
if (url.startsWith("/api/logs/detail")) return Response.json({ enabled: false });
|
||||
return Response.json({});
|
||||
})
|
||||
);
|
||||
|
||||
await render(<RequestLoggerV2 />);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const row = Array.from(container.querySelectorAll("tbody tr")).find((candidate) =>
|
||||
candidate.textContent?.includes("gpt-cache")
|
||||
);
|
||||
expect(row?.textContent).toContain("TI: 1,000");
|
||||
expect(row?.textContent).toContain("TO: 250");
|
||||
expect(row?.textContent).toContain("CR: 800");
|
||||
expect(row?.textContent).toContain("CW: 120");
|
||||
expect(row?.textContent).toContain("↓20");
|
||||
|
||||
const emptyRow = Array.from(container.querySelectorAll("tbody tr")).find((candidate) =>
|
||||
candidate.textContent?.includes("gpt-no-cache")
|
||||
);
|
||||
expect(emptyRow?.textContent).toContain("TI: 1,000");
|
||||
expect(emptyRow?.textContent).toContain("TO: 250");
|
||||
expect(emptyRow?.textContent).not.toContain("CR:");
|
||||
expect(emptyRow?.textContent).not.toContain("CW:");
|
||||
});
|
||||
|
||||
it("distinguishes cache read from cache write in the detail view", async () => {
|
||||
await render(
|
||||
<RequestLoggerDetail
|
||||
log={populatedLog}
|
||||
detail={populatedLog}
|
||||
loading={false}
|
||||
debugEnabled={false}
|
||||
onClose={noop}
|
||||
onCopy={async () => true}
|
||||
/>
|
||||
);
|
||||
|
||||
const inputGroup = container.querySelector('[data-testid="token-group-input"]');
|
||||
const outputGroup = container.querySelector('[data-testid="token-group-output"]');
|
||||
expect(inputGroup?.textContent).toContain("Total In: 1,000");
|
||||
expect(inputGroup?.textContent).toContain("Cache Read: 800");
|
||||
expect(inputGroup?.textContent).toContain("Cache Write: 120");
|
||||
expect(inputGroup?.textContent).toContain("Compressed:");
|
||||
expect(outputGroup?.textContent).toContain("Total Out: 250");
|
||||
expect(outputGroup?.textContent).toContain("Reasoning: 50");
|
||||
});
|
||||
|
||||
it("handles historical null and zero cache values without inventing usage", async () => {
|
||||
const emptyCacheLog = {
|
||||
...populatedLog,
|
||||
id: "log-no-cache",
|
||||
tokens: { ...populatedLog.tokens, cacheRead: null, cacheWrite: 0 },
|
||||
};
|
||||
|
||||
await render(
|
||||
<RequestLoggerDetail
|
||||
log={emptyCacheLog}
|
||||
detail={emptyCacheLog}
|
||||
loading={false}
|
||||
debugEnabled={false}
|
||||
onClose={noop}
|
||||
onCopy={async () => true}
|
||||
/>
|
||||
);
|
||||
|
||||
const inputGroup = container.querySelector('[data-testid="token-group-input"]');
|
||||
expect(inputGroup?.textContent).toContain("Cache Read: N/A");
|
||||
expect(inputGroup?.textContent).toContain("Cache Write: 0");
|
||||
expect(inputGroup?.textContent).toContain("Total In: 1,000");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user