fix: secure assistant generation telemetry

This commit is contained in:
ajnart committed 2026-08-13 19:56:27 +02:00
1 parent ca8163c061
commit 4060f9299f
11 files changed
+181 -10

No files matched your search

+3 -1
View File
@@ -119,7 +119,9 @@ processing it. The details separate Homarr's end-to-end timing from OpenRouter's
generation time, and provider throughput, and can include normalized and native token counts, moderation
latency, cache discount, fallback count, service tier, data region, BYOK status, routed provider, generation
identifier, exact cost, and upstream inference cost. Provider credentials remain server-side; the browser
can request only generation identifiers already stored on a conversation owned by the signed-in user.
can request only generation identifiers already stored on a conversation owned by the signed-in user and
signed by the Homarr server for that user and conversation. Successful generation lookups are cached
briefly so polling and multi-step requests do not repeatedly query OpenRouter for the same record.
When a provider omits cost but model discovery includes prompt and completion pricing, Homarr labels the
calculated value as estimated. Missing provider data is shown as not reported rather than inferred.
@@ -8,7 +8,11 @@ import { parse, stringify } from "superjson";
import { z } from "zod/v4";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { getAssistantContextEntitiesAsync, getSelectedModelDetailsAsync } from "@homarr/api/assistant";
import {
createAssistantGenerationAccessToken,
getAssistantContextEntitiesAsync,
getSelectedModelDetailsAsync,
} from "@homarr/api/assistant";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/common/env";
import { decryptSecret } from "@homarr/common/server";
@@ -538,6 +542,15 @@ export async function POST(request: Request) {
? { upstreamCost: asFiniteNumber(telemetry?.upstreamCost) }
: {}),
...(generationId ? { generationId } : {}),
...(generationId
? {
generationAccessToken: createAssistantGenerationAccessToken({
userId: session.user.id,
threadId: thread.id,
generationId,
}),
}
: {}),
...(typeof telemetry?.routedProvider === "string" ? { routedProvider: telemetry.routedProvider } : {}),
...(asFiniteNumber(telemetry?.fallbackCount) !== undefined
? { fallbackCount: asFiniteNumber(telemetry?.fallbackCount) }
@@ -30,6 +30,7 @@ export type AssistantRequestStep = {
upstreamCost?: number;
cacheDiscount?: number;
generationId?: string;
generationAccessToken?: string;
routedProvider?: string;
finishReason?: string;
nativeFinishReason?: string;
@@ -189,6 +190,9 @@ export const getAssistantTelemetry = (metadata: unknown): AssistantRequestTeleme
? { cacheDiscount: getFiniteNonNegativeNumber(candidate.cacheDiscount) }
: {}),
...(typeof candidate.generationId === "string" ? { generationId: candidate.generationId } : {}),
...(typeof candidate.generationAccessToken === "string"
? { generationAccessToken: candidate.generationAccessToken }
: {}),
...(typeof candidate.routedProvider === "string" ? { routedProvider: candidate.routedProvider } : {}),
...(typeof candidate.finishReason === "string" ? { finishReason: candidate.finishReason } : {}),
...(typeof candidate.nativeFinishReason === "string"
@@ -739,6 +739,7 @@ const RequestTelemetry = () => {
: `${t("usage.contextWindow")}: ${t("usage.notReported")}`;
const displayedThroughput = telemetry.providerOutputTokensPerSecond ?? telemetry.outputTokensPerSecond;
const hasProviderMetrics =
telemetry.provider === "openrouter" ||
generationQuery.isFetching ||
telemetry.providerOutputTokensPerSecond !== undefined ||
telemetry.generationTimeMs !== undefined ||
@@ -991,6 +992,11 @@ const RequestTelemetry = () => {
</Text>
</Group>
)}
{!generationQuery.isFetching && generationQuery.data?.complete === false && (
<Text size="xs" c="dimmed">
{t("usage.providerDetailsPending")}
</Text>
)}
<Box className={classes.usageGrid}>
<div>
<Text size="xs" c="dimmed">
@@ -0,0 +1,34 @@
import { createHmac, hkdfSync, timingSafeEqual } from "node:crypto";
import { env } from "@homarr/common/env";
const getGenerationTelemetrySecret = () =>
Buffer.from(
hkdfSync("sha256", Buffer.from(env.SECRET_ENCRYPTION_KEY, "hex"), "", "assistant-generation-telemetry", 32),
);
const getGenerationTelemetryPayload = ({
userId,
threadId,
generationId,
}: {
userId: string;
threadId: string;
generationId: string;
}) => `${userId}\0${threadId}\0${generationId}`;
export const createAssistantGenerationAccessToken = (input: {
userId: string;
threadId: string;
generationId: string;
}) =>
createHmac("sha256", getGenerationTelemetrySecret()).update(getGenerationTelemetryPayload(input)).digest("base64url");
export const verifyAssistantGenerationAccessToken = (
input: { userId: string; threadId: string; generationId: string },
accessToken: string,
) => {
const expected = Buffer.from(createAssistantGenerationAccessToken(input));
const actual = Buffer.from(accessToken);
return expected.length === actual.length && timingSafeEqual(expected, actual);
};
@@ -1,5 +1,9 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
const generationTelemetryCache = new Map<string, { expiresAt: number; value: OpenRouterGenerationTelemetry }>();
const generationTelemetryCacheTtlMs = 5 * 60_000;
const generationTelemetryCacheMaxEntries = 512;
export type OpenRouterGenerationTelemetry = {
generationId?: string;
routedProvider?: string;
@@ -50,13 +54,17 @@ export const parseOpenRouterGenerationTelemetry = (value: unknown): OpenRouterGe
const generationTimeMs = asFiniteNonNegativeNumber(data.generation_time);
const providerResponses = Array.isArray(data.provider_responses) ? data.provider_responses : undefined;
const parsedProviderResponses = providerResponses?.map(asRecord).filter((response) => response !== null);
const providerResponseStatuses = parsedProviderResponses?.map((response) =>
asFiniteNonNegativeNumber(response.status),
);
const failedProviderResponses = parsedProviderResponses?.filter((response) => {
const status = asFiniteNonNegativeNumber(response.status);
return status !== undefined && (status < 200 || status >= 300);
});
const fallbackCount = providerResponses
? failedProviderResponses && failedProviderResponses.length > 0
? failedProviderResponses.length
? providerResponseStatuses?.length === providerResponses.length &&
providerResponseStatuses.every((status) => status !== undefined)
? (failedProviderResponses?.length ?? 0)
: Math.max(providerResponses.length - 1, 0)
: undefined;
const fallbackLatencies = failedProviderResponses?.map((response) => asFiniteNonNegativeNumber(response.latency));
@@ -128,12 +136,31 @@ export const fetchOpenRouterGenerationTelemetryAsync = async ({
generationId: string;
headers: Record<string, string>;
}): Promise<OpenRouterGenerationTelemetry | null> => {
const cacheKey = `${baseUrl}\0${generationId}`;
const cached = generationTelemetryCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
generationTelemetryCache.delete(cacheKey);
generationTelemetryCache.set(cacheKey, cached);
return cached.value;
}
if (cached) generationTelemetryCache.delete(cacheKey);
try {
const endpoint = new URL(`${baseUrl.replace(/\/$/, "")}/generation`);
endpoint.searchParams.set("id", generationId);
const response = await fetchWithTrustedCertificatesAsync(endpoint, { headers, timeout: 3000 });
if (!response.ok) return null;
return parseOpenRouterGenerationTelemetry(await response.json());
const telemetry = parseOpenRouterGenerationTelemetry(await response.json());
if (!telemetry) return null;
if (generationTelemetryCache.size >= generationTelemetryCacheMaxEntries) {
const oldestKey = generationTelemetryCache.keys().next().value;
if (oldestKey !== undefined) generationTelemetryCache.delete(oldestKey);
}
generationTelemetryCache.set(cacheKey, {
expiresAt: Date.now() + generationTelemetryCacheTtlMs,
value: telemetry,
});
return telemetry;
} catch {
return null;
}
+1
View File
@@ -5,3 +5,4 @@ export {
type AssistantContextEntity,
} from "./router/assistant";
export type { OpenRouterGenerationTelemetry } from "./assistant-generation-telemetry";
export { createAssistantGenerationAccessToken } from "./assistant-generation-access";
+20 -4
View File
@@ -27,6 +27,7 @@ import {
import type { createTRPCContext } from "../trpc";
import { fetchOpenRouterGenerationTelemetryAsync } from "../assistant-generation-telemetry";
import { verifyAssistantGenerationAccessToken } from "../assistant-generation-access";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
import { boardRouter } from "./board";
@@ -174,7 +175,13 @@ const getGenerationIdsFromMessageContent = (content: unknown) => {
.flatMap((step) => {
if (!step || typeof step !== "object" || Array.isArray(step)) return [];
const generationId = (step as { generationId?: unknown }).generationId;
return typeof generationId === "string" && /^gen-[A-Za-z0-9_-]{1,128}$/.test(generationId) ? [generationId] : [];
const accessToken = (step as { generationAccessToken?: unknown }).generationAccessToken;
return typeof generationId === "string" &&
/^gen-[A-Za-z0-9_-]{1,128}$/.test(generationId) &&
typeof accessToken === "string" &&
/^[A-Za-z0-9_-]{43}$/.test(accessToken)
? [{ generationId, accessToken }]
: [];
})
.slice(0, 8);
};
@@ -714,10 +721,19 @@ export const assistantRouter = createTRPCRouter({
return { complete: true, generations: [] };
}
const generationIds = getGenerationIdsFromMessageContent(parse(message.content));
if (generationIds.length === 0) return { complete: true, generations: [] };
const generationReferences = getGenerationIdsFromMessageContent(parse(message.content)).filter((reference) =>
verifyAssistantGenerationAccessToken(
{
userId: ctx.session.user.id,
threadId: thread.id,
generationId: reference.generationId,
},
reference.accessToken,
),
);
if (generationReferences.length === 0) return { complete: true, generations: [] };
const generations = await Promise.all(
generationIds.map((generationId) =>
generationReferences.map(({ generationId }) =>
fetchOpenRouterGenerationTelemetryAsync({
baseUrl: configuration.baseUrl,
generationId,
@@ -0,0 +1,28 @@
import { describe, expect, test, vi } from "vitest";
vi.mock("@homarr/common/env", () => ({
env: { SECRET_ENCRYPTION_KEY: "a".repeat(64) },
}));
import {
createAssistantGenerationAccessToken,
verifyAssistantGenerationAccessToken,
} from "../assistant-generation-access";
describe("assistant generation access token", () => {
const input = {
userId: "user-1",
threadId: "thread-1",
generationId: "gen-example",
};
test("binds a generation to its user and conversation", () => {
const accessToken = createAssistantGenerationAccessToken(input);
expect(accessToken).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(verifyAssistantGenerationAccessToken(input, accessToken)).toBe(true);
expect(verifyAssistantGenerationAccessToken({ ...input, userId: "user-2" }, accessToken)).toBe(false);
expect(verifyAssistantGenerationAccessToken({ ...input, threadId: "thread-2" }, accessToken)).toBe(false);
expect(verifyAssistantGenerationAccessToken({ ...input, generationId: "gen-other" }, accessToken)).toBe(false);
});
});
@@ -1,8 +1,13 @@
import { describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
import { parseOpenRouterGenerationTelemetry } from "../assistant-generation-telemetry";
import {
fetchOpenRouterGenerationTelemetryAsync,
parseOpenRouterGenerationTelemetry,
} from "../assistant-generation-telemetry";
describe("parseOpenRouterGenerationTelemetry", () => {
test("uses provider-native tokens for context and throughput", () => {
@@ -63,4 +68,38 @@ describe("parseOpenRouterGenerationTelemetry", () => {
expect(parseOpenRouterGenerationTelemetry({ data: [] })).toBeNull();
expect(parseOpenRouterGenerationTelemetry({ data: { latency: -1, total_cost: "invalid" } })).toBeNull();
});
test("does not count additional successful provider responses as fallbacks", () => {
expect(
parseOpenRouterGenerationTelemetry({
id: "gen-success",
provider_responses: [
{ status: 200, latency: 100 },
{ status: 200, latency: 120 },
],
}),
).toMatchObject({ fallbackCount: 0 });
});
test("caches completed generation records", async () => {
const mockedFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
mockedFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: { id: "gen-cached", native_tokens_completion: 4 } }),
} as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>);
const input = {
baseUrl: "https://openrouter.ai/api/v1",
generationId: "gen-cached",
headers: { Authorization: "Bearer test" },
};
await expect(fetchOpenRouterGenerationTelemetryAsync(input)).resolves.toMatchObject({
generationId: "gen-cached",
outputTokens: 4,
});
await expect(fetchOpenRouterGenerationTelemetryAsync(input)).resolves.toMatchObject({
generationId: "gen-cached",
});
expect(mockedFetch).toHaveBeenCalledTimes(1);
});
});
+1
View File
@@ -2084,6 +2084,7 @@
"observedThroughput": "Observed throughput",
"providerTiming": "Provider reported",
"loadingProviderDetails": "Loading generation details…",
"providerDetailsPending": "OpenRouter is still processing some generation details. Reopen to retry.",
"providerLatency": "Provider latency",
"generationTime": "Generation time",
"moderationLatency": "Moderation",