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",