From 4060f9299fe90ccb10bd24273c7b8662beefdf3b Mon Sep 17 00:00:00 2001 From: ajnart Date: Sun, 2 Aug 2026 09:09:01 +0200 Subject: [PATCH] fix: secure assistant generation telemetry --- apps/docs/docs/management/assistant.mdx | 4 +- .../src/app/api/assistant/chat/route.ts | 15 ++++++- .../assistant/assistant-message-metadata.ts | 4 ++ .../components/assistant/assistant-panel.tsx | 6 +++ .../api/src/assistant-generation-access.ts | 34 +++++++++++++++ .../api/src/assistant-generation-telemetry.ts | 33 +++++++++++++-- packages/api/src/assistant.ts | 1 + packages/api/src/router/assistant.ts | 24 +++++++++-- .../test/assistant-generation-access.spec.ts | 28 +++++++++++++ .../assistant-generation-telemetry.spec.ts | 41 ++++++++++++++++++- packages/translation/src/lang/en.json | 1 + 11 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 packages/api/src/assistant-generation-access.ts create mode 100644 packages/api/src/test/assistant-generation-access.spec.ts diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 5129d6f48..623cca18a 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -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. diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index ebab3ab64..ff39b143c 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -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) } diff --git a/apps/nextjs/src/components/assistant/assistant-message-metadata.ts b/apps/nextjs/src/components/assistant/assistant-message-metadata.ts index a37df1053..14d4aaf44 100644 --- a/apps/nextjs/src/components/assistant/assistant-message-metadata.ts +++ b/apps/nextjs/src/components/assistant/assistant-message-metadata.ts @@ -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" diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 3815357ea..9e308b3a5 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -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 = () => { )} + {!generationQuery.isFetching && generationQuery.data?.complete === false && ( + + {t("usage.providerDetailsPending")} + + )}
diff --git a/packages/api/src/assistant-generation-access.ts b/packages/api/src/assistant-generation-access.ts new file mode 100644 index 000000000..f39f7f092 --- /dev/null +++ b/packages/api/src/assistant-generation-access.ts @@ -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); +}; diff --git a/packages/api/src/assistant-generation-telemetry.ts b/packages/api/src/assistant-generation-telemetry.ts index 6a4106457..418fa6c55 100644 --- a/packages/api/src/assistant-generation-telemetry.ts +++ b/packages/api/src/assistant-generation-telemetry.ts @@ -1,5 +1,9 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +const generationTelemetryCache = new Map(); +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; }): Promise => { + 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; } diff --git a/packages/api/src/assistant.ts b/packages/api/src/assistant.ts index c7731c22f..e6ac0cee0 100644 --- a/packages/api/src/assistant.ts +++ b/packages/api/src/assistant.ts @@ -5,3 +5,4 @@ export { type AssistantContextEntity, } from "./router/assistant"; export type { OpenRouterGenerationTelemetry } from "./assistant-generation-telemetry"; +export { createAssistantGenerationAccessToken } from "./assistant-generation-access"; diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index a56abcc20..11752f2f1 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -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, diff --git a/packages/api/src/test/assistant-generation-access.spec.ts b/packages/api/src/test/assistant-generation-access.spec.ts new file mode 100644 index 000000000..ec9b26b4f --- /dev/null +++ b/packages/api/src/test/assistant-generation-access.spec.ts @@ -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); + }); +}); diff --git a/packages/api/src/test/assistant-generation-telemetry.spec.ts b/packages/api/src/test/assistant-generation-telemetry.spec.ts index 859b860a7..480a29b45 100644 --- a/packages/api/src/test/assistant-generation-telemetry.spec.ts +++ b/packages/api/src/test/assistant-generation-telemetry.spec.ts @@ -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>); + 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); + }); }); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index a606f70ff..a1c76e573 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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",