feat: enrich assistant request telemetry

This commit is contained in:
ajnart committed 2026-08-02 08:51:48 +02:00
1 parent d562706c8b
commit 04520cf67c
10 files changed
+798 -39

No files matched your search

+7 -2
View File
@@ -113,8 +113,13 @@ Each completed response includes a circular context-window meter. Select it to o
the provider reports them: input, output, cached-input and reasoning tokens, used and remaining context,
time to first output, total duration, output speed, agent-step timing, and cost. OpenRouter-reported cost
is labeled as reported.
OpenRouter responses also show the routed provider, generation identifier, and upstream inference cost when
the endpoint supplies those fields.
OpenRouter responses request inline usage and routing metadata. When request details are opened, Homarr also
loads the server-side generation record for each model step and briefly polls while OpenRouter finishes
processing it. The details separate Homarr's end-to-end timing from OpenRouter's provider latency,
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.
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.
+100 -22
View File
@@ -119,21 +119,33 @@ const createProviderTelemetryExtractor = (): MetadataExtractor => {
if (!body) return;
const usage = asRecord(body.usage);
const costDetails = asRecord(usage?.cost_details);
const routerMetadata = asRecord(body.openrouter_metadata);
const endpoints = asRecord(routerMetadata?.endpoints);
const selectedEndpoint = Array.isArray(endpoints?.available)
? endpoints.available.map(asRecord).find((endpoint) => endpoint?.selected === true)
: undefined;
const generationId = typeof body.id === "string" ? body.id : undefined;
const routedProvider =
typeof body.provider === "string"
? body.provider
: typeof usage?.provider === "string"
? usage.provider
: undefined;
: typeof selectedEndpoint?.provider === "string"
? selectedEndpoint.provider
: undefined;
const cost = asFiniteNumber(usage?.cost);
const upstreamCost = asFiniteNumber(costDetails?.upstream_inference_cost);
const routerAttempt = asFiniteNumber(routerMetadata?.attempt);
metadata = {
...metadata,
...(generationId ? { generationId } : {}),
...(routedProvider ? { routedProvider } : {}),
...(cost !== undefined ? { cost } : {}),
...(upstreamCost !== undefined ? { upstreamCost } : {}),
...(routerAttempt !== undefined ? { fallbackCount: Math.max(routerAttempt - 1, 0) } : {}),
...(typeof routerMetadata?.strategy === "string" ? { routerStrategy: routerMetadata.strategy } : {}),
...(typeof routerMetadata?.region === "string" ? { routerRegion: routerMetadata.region } : {}),
...(typeof routerMetadata?.is_byok === "boolean" ? { isByok: routerMetadata.is_byok ? 1 : 0 } : {}),
};
};
const build = () => (Object.keys(metadata).length > 0 ? { homarrTelemetry: metadata } : undefined);
@@ -252,6 +264,16 @@ const toUsageMetadata = (usage: {
: {}),
});
const sumCompleteStepMetric = (
steps: AssistantRequestStep[],
getValue: (step: AssistantRequestStep) => number | undefined,
) => {
if (steps.length === 0) return undefined;
const values = steps.map(getValue);
if (values.some((value) => value === undefined)) return undefined;
return (values as number[]).reduce((sum, value) => sum + value, 0);
};
const safeToolError = (error: unknown) => {
if (error instanceof Error && "code" in error) {
switch ((error as Error & { code: string }).code) {
@@ -428,14 +450,20 @@ export async function POST(request: Request) {
: {};
const providerHeaders = {
...(configuration.provider === "openrouter"
? { "HTTP-Referer": "https://homarr.dev", "X-Title": "Homarr Assistant" }
? {
"HTTP-Referer": "https://homarr.dev",
"X-Title": "Homarr Assistant",
"X-OpenRouter-Metadata": "enabled",
}
: {}),
...customHeaders,
};
const providerApiKey = configuration.encryptedApiKey ? decryptSecret(configuration.encryptedApiKey) : undefined;
const providerName = `homarr-${configuration.provider}`;
const provider = createOpenAICompatible({
name: `homarr-${configuration.provider}`,
name: providerName,
baseURL: configuration.baseUrl,
apiKey: configuration.encryptedApiKey ? decryptSecret(configuration.encryptedApiKey) : undefined,
apiKey: providerApiKey,
headers: providerHeaders,
includeUsage: true,
metadataExtractor: createProviderTelemetryExtractor(),
@@ -464,6 +492,8 @@ export async function POST(request: Request) {
timeout: { totalMs: 55_000, stepMs: 30_000, toolMs: 30_000 },
maxRetries: 2,
reasoning: parsed.data.reasoning === "auto" ? undefined : parsed.data.reasoning,
providerOptions:
configuration.provider === "openrouter" ? { [providerName]: { usage: { include: true } } } : undefined,
toolApproval,
experimental_toolApprovalSecret: getToolApprovalSecret(),
onChunk: ({ chunk }) => {
@@ -484,17 +514,13 @@ export async function POST(request: Request) {
},
onStepFinish: ({ performance, providerMetadata, response, usage }) => {
const telemetry = asRecord(providerMetadata?.homarrTelemetry);
const cost = asFiniteNumber(telemetry?.cost);
const stepUpstreamCost = asFiniteNumber(telemetry?.upstreamCost);
if (cost !== undefined) {
reportedCost += cost;
hasReportedCost = true;
}
if (stepUpstreamCost !== undefined) {
upstreamCost += stepUpstreamCost;
hasUpstreamCost = true;
}
requestSteps.push({
const generationId =
typeof telemetry?.generationId === "string"
? telemetry.generationId
: typeof response.id === "string"
? response.id
: undefined;
const step: AssistantRequestStep = {
index: requestSteps.length + 1,
durationMs: performance.stepTimeMs,
modelDurationMs: performance.responseTimeMs,
@@ -507,11 +533,28 @@ export async function POST(request: Request) {
: {}),
...(usage.inputTokens !== undefined ? { inputTokens: usage.inputTokens } : {}),
...(usage.outputTokens !== undefined ? { outputTokens: usage.outputTokens } : {}),
...(cost !== undefined ? { cost } : {}),
...(stepUpstreamCost !== undefined ? { upstreamCost: stepUpstreamCost } : {}),
...(typeof response.id === "string" ? { generationId: response.id } : {}),
...(asFiniteNumber(telemetry?.cost) !== undefined ? { cost: asFiniteNumber(telemetry?.cost) } : {}),
...(asFiniteNumber(telemetry?.upstreamCost) !== undefined
? { upstreamCost: asFiniteNumber(telemetry?.upstreamCost) }
: {}),
...(generationId ? { generationId } : {}),
...(typeof telemetry?.routedProvider === "string" ? { routedProvider: telemetry.routedProvider } : {}),
});
...(asFiniteNumber(telemetry?.fallbackCount) !== undefined
? { fallbackCount: asFiniteNumber(telemetry?.fallbackCount) }
: {}),
...(typeof telemetry?.routerStrategy === "string" ? { routerStrategy: telemetry.routerStrategy } : {}),
...(typeof telemetry?.routerRegion === "string" ? { routerRegion: telemetry.routerRegion } : {}),
...(telemetry?.isByok === 0 || telemetry?.isByok === 1 ? { isByok: telemetry.isByok === 1 } : {}),
};
requestSteps.push(step);
if (step.cost !== undefined) {
reportedCost += step.cost;
hasReportedCost = true;
}
if (step.upstreamCost !== undefined) {
upstreamCost += step.upstreamCost;
hasUpstreamCost = true;
}
},
onError: ({ error }) => {
logger.error("Assistant response stream failed", {
@@ -552,8 +595,29 @@ export async function POST(request: Request) {
}
const completedAt = Date.now();
const usage = toUsageMetadata(part.totalUsage);
const contextUsed = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
const aiUsage = toUsageMetadata(part.totalUsage);
const providerInputTokens = sumCompleteStepMetric(requestSteps, (step) => step.inputTokens);
const providerOutputTokens = sumCompleteStepMetric(requestSteps, (step) => step.outputTokens);
const providerCachedInputTokens = sumCompleteStepMetric(requestSteps, (step) => step.cachedInputTokens);
const providerReasoningTokens = sumCompleteStepMetric(requestSteps, (step) => step.reasoningTokens);
const inputTokens = providerInputTokens ?? aiUsage.inputTokens;
const outputTokens = providerOutputTokens ?? aiUsage.outputTokens;
const usage: AssistantUsage = {
...aiUsage,
...(inputTokens !== undefined ? { inputTokens } : {}),
...(outputTokens !== undefined ? { outputTokens } : {}),
...(inputTokens !== undefined && outputTokens !== undefined
? { totalTokens: inputTokens + outputTokens }
: {}),
...(providerCachedInputTokens !== undefined ? { cachedInputTokens: providerCachedInputTokens } : {}),
...(providerReasoningTokens !== undefined ? { reasoningTokens: providerReasoningTokens } : {}),
};
const latestStep = requestSteps.at(-1);
const latestContextUsed =
latestStep?.inputTokens !== undefined && latestStep.outputTokens !== undefined
? latestStep.inputTokens + latestStep.outputTokens
: undefined;
const contextUsed = latestContextUsed ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
const promptPrice = asFiniteNumber(selectedModel?.promptPrice);
const completionPrice = asFiniteNumber(selectedModel?.completionPrice);
const estimatedCost =
@@ -564,6 +628,16 @@ export async function POST(request: Request) {
const generatedSeconds = requestSteps.reduce((sum, step) => sum + step.modelDurationMs, 0) / 1000;
const outputTokensPerSecond =
usage.outputTokens !== undefined && generatedSeconds > 0 ? usage.outputTokens / generatedSeconds : undefined;
const providerGenerationTimeMs = sumCompleteStepMetric(requestSteps, (step) => step.generationTimeMs);
const providerGeneratedTokens = sumCompleteStepMetric(requestSteps, (step) => step.outputTokens);
const providerOutputTokensPerSecond =
providerGenerationTimeMs !== undefined &&
providerGenerationTimeMs > 0 &&
providerGeneratedTokens !== undefined
? providerGeneratedTokens / (providerGenerationTimeMs / 1000)
: undefined;
const cacheDiscount = sumCompleteStepMetric(requestSteps, (step) => step.cacheDiscount);
const fallbackCount = sumCompleteStepMetric(requestSteps, (step) => step.fallbackCount);
return {
usage,
@@ -574,13 +648,17 @@ export async function POST(request: Request) {
durationMs: completedAt - requestStartedAt,
...(firstOutputAt !== undefined ? { timeToFirstOutputMs: firstOutputAt - requestStartedAt } : {}),
...(outputTokensPerSecond !== undefined ? { outputTokensPerSecond } : {}),
...(providerOutputTokensPerSecond !== undefined ? { providerOutputTokensPerSecond } : {}),
...(providerGenerationTimeMs !== undefined ? { generationTimeMs: providerGenerationTimeMs } : {}),
...(contextUsed > 0 ? { contextUsed } : {}),
...(selectedModel?.contextLength && contextUsed > 0
? { contextUtilization: Math.min(contextUsed / selectedModel.contextLength, 1) }
: {}),
...(cost !== undefined ? { cost, costType: hasReportedCost ? "reported" : "estimated" } : {}),
...(hasUpstreamCost ? { upstreamCost } : {}),
finishReason: part.finishReason,
...(cacheDiscount !== undefined ? { cacheDiscount } : {}),
...(fallbackCount !== undefined ? { fallbackCount } : {}),
finishReason: latestStep?.finishReason ?? part.finishReason,
},
},
};
@@ -19,7 +19,35 @@ describe("getAssistantTelemetry", () => {
contextUtilization: 0.015625,
cost: 0.001,
costType: "reported",
steps: [{ index: 1, durationMs: 900, modelDurationMs: 800, toolDurationMs: 100 }],
providerOutputTokensPerSecond: 37.9,
generationTimeMs: 1451,
cacheDiscount: 0.00003,
fallbackCount: 1,
steps: [
{
index: 1,
durationMs: 900,
modelDurationMs: 800,
toolDurationMs: 100,
providerLatencyMs: 964,
generationTimeMs: 1451,
providerOutputTokensPerSecond: 37.9,
inputTokens: 12_777,
outputTokens: 55,
normalizedInputTokens: 12_799,
normalizedOutputTokens: 62,
cachedInputTokens: 2048,
reasoningTokens: 7,
cacheDiscount: 0.00003,
fallbackCount: 1,
routedProvider: "Alibaba",
serviceTier: "default",
dataRegion: "US",
isByok: false,
streamed: true,
cancelled: false,
},
],
},
},
}),
@@ -29,6 +57,19 @@ describe("getAssistantTelemetry", () => {
durationMs: 1000,
contextLength: 128_000,
costType: "reported",
providerOutputTokensPerSecond: 37.9,
generationTimeMs: 1451,
cacheDiscount: 0.00003,
fallbackCount: 1,
steps: [
expect.objectContaining({
providerLatencyMs: 964,
inputTokens: 12_777,
cachedInputTokens: 2048,
routedProvider: "Alibaba",
serviceTier: "default",
}),
],
});
});
@@ -16,12 +16,32 @@ export type AssistantRequestStep = {
toolDurationMs: number;
timeToFirstOutputMs?: number;
outputTokensPerSecond?: number;
providerLatencyMs?: number;
generationTimeMs?: number;
moderationLatencyMs?: number;
providerOutputTokensPerSecond?: number;
inputTokens?: number;
outputTokens?: number;
normalizedInputTokens?: number;
normalizedOutputTokens?: number;
cachedInputTokens?: number;
reasoningTokens?: number;
cost?: number;
upstreamCost?: number;
cacheDiscount?: number;
generationId?: string;
routedProvider?: string;
finishReason?: string;
nativeFinishReason?: string;
serviceTier?: string;
dataRegion?: string;
routerStrategy?: string;
routerRegion?: string;
fallbackCount?: number;
fallbackLatencyMs?: number;
isByok?: boolean;
streamed?: boolean;
cancelled?: boolean;
};
export type AssistantRequestTelemetry = {
@@ -33,11 +53,16 @@ export type AssistantRequestTelemetry = {
durationMs?: number;
timeToFirstOutputMs?: number;
outputTokensPerSecond?: number;
providerOutputTokensPerSecond?: number;
generationTimeMs?: number;
contextLength?: number;
contextUsed?: number;
contextUtilization?: number;
cost?: number;
upstreamCost?: number;
cacheDiscount?: number;
fallbackCount?: number;
fallbackLatencyMs?: number;
costType?: "reported" | "estimated";
finishReason?: string;
steps: AssistantRequestStep[];
@@ -124,20 +149,64 @@ export const getAssistantTelemetry = (metadata: unknown): AssistantRequestTeleme
...(getFiniteNonNegativeNumber(candidate.outputTokensPerSecond) !== undefined
? { outputTokensPerSecond: getFiniteNonNegativeNumber(candidate.outputTokensPerSecond) }
: {}),
...(getFiniteNonNegativeNumber(candidate.providerLatencyMs) !== undefined
? { providerLatencyMs: getFiniteNonNegativeNumber(candidate.providerLatencyMs) }
: {}),
...(getFiniteNonNegativeNumber(candidate.generationTimeMs) !== undefined
? { generationTimeMs: getFiniteNonNegativeNumber(candidate.generationTimeMs) }
: {}),
...(getFiniteNonNegativeNumber(candidate.moderationLatencyMs) !== undefined
? { moderationLatencyMs: getFiniteNonNegativeNumber(candidate.moderationLatencyMs) }
: {}),
...(getFiniteNonNegativeNumber(candidate.providerOutputTokensPerSecond) !== undefined
? { providerOutputTokensPerSecond: getFiniteNonNegativeNumber(candidate.providerOutputTokensPerSecond) }
: {}),
...(getFiniteNonNegativeNumber(candidate.inputTokens) !== undefined
? { inputTokens: getFiniteNonNegativeNumber(candidate.inputTokens) }
: {}),
...(getFiniteNonNegativeNumber(candidate.outputTokens) !== undefined
? { outputTokens: getFiniteNonNegativeNumber(candidate.outputTokens) }
: {}),
...(getFiniteNonNegativeNumber(candidate.normalizedInputTokens) !== undefined
? { normalizedInputTokens: getFiniteNonNegativeNumber(candidate.normalizedInputTokens) }
: {}),
...(getFiniteNonNegativeNumber(candidate.normalizedOutputTokens) !== undefined
? { normalizedOutputTokens: getFiniteNonNegativeNumber(candidate.normalizedOutputTokens) }
: {}),
...(getFiniteNonNegativeNumber(candidate.cachedInputTokens) !== undefined
? { cachedInputTokens: getFiniteNonNegativeNumber(candidate.cachedInputTokens) }
: {}),
...(getFiniteNonNegativeNumber(candidate.reasoningTokens) !== undefined
? { reasoningTokens: getFiniteNonNegativeNumber(candidate.reasoningTokens) }
: {}),
...(getFiniteNonNegativeNumber(candidate.cost) !== undefined
? { cost: getFiniteNonNegativeNumber(candidate.cost) }
: {}),
...(getFiniteNonNegativeNumber(candidate.upstreamCost) !== undefined
? { upstreamCost: getFiniteNonNegativeNumber(candidate.upstreamCost) }
: {}),
...(getFiniteNonNegativeNumber(candidate.cacheDiscount) !== undefined
? { cacheDiscount: getFiniteNonNegativeNumber(candidate.cacheDiscount) }
: {}),
...(typeof candidate.generationId === "string" ? { generationId: candidate.generationId } : {}),
...(typeof candidate.routedProvider === "string" ? { routedProvider: candidate.routedProvider } : {}),
...(typeof candidate.finishReason === "string" ? { finishReason: candidate.finishReason } : {}),
...(typeof candidate.nativeFinishReason === "string"
? { nativeFinishReason: candidate.nativeFinishReason }
: {}),
...(typeof candidate.serviceTier === "string" ? { serviceTier: candidate.serviceTier } : {}),
...(typeof candidate.dataRegion === "string" ? { dataRegion: candidate.dataRegion } : {}),
...(typeof candidate.routerStrategy === "string" ? { routerStrategy: candidate.routerStrategy } : {}),
...(typeof candidate.routerRegion === "string" ? { routerRegion: candidate.routerRegion } : {}),
...(getFiniteNonNegativeNumber(candidate.fallbackCount) !== undefined
? { fallbackCount: getFiniteNonNegativeNumber(candidate.fallbackCount) }
: {}),
...(getFiniteNonNegativeNumber(candidate.fallbackLatencyMs) !== undefined
? { fallbackLatencyMs: getFiniteNonNegativeNumber(candidate.fallbackLatencyMs) }
: {}),
...(typeof candidate.isByok === "boolean" ? { isByok: candidate.isByok } : {}),
...(typeof candidate.streamed === "boolean" ? { streamed: candidate.streamed } : {}),
...(typeof candidate.cancelled === "boolean" ? { cancelled: candidate.cancelled } : {}),
},
];
})
@@ -158,6 +227,12 @@ export const getAssistantTelemetry = (metadata: unknown): AssistantRequestTeleme
...(getFiniteNonNegativeNumber(value.outputTokensPerSecond) !== undefined
? { outputTokensPerSecond: getFiniteNonNegativeNumber(value.outputTokensPerSecond) }
: {}),
...(getFiniteNonNegativeNumber(value.providerOutputTokensPerSecond) !== undefined
? { providerOutputTokensPerSecond: getFiniteNonNegativeNumber(value.providerOutputTokensPerSecond) }
: {}),
...(getFiniteNonNegativeNumber(value.generationTimeMs) !== undefined
? { generationTimeMs: getFiniteNonNegativeNumber(value.generationTimeMs) }
: {}),
...(getFiniteNonNegativeNumber(value.contextLength) !== undefined
? { contextLength: getFiniteNonNegativeNumber(value.contextLength) }
: {}),
@@ -171,6 +246,15 @@ export const getAssistantTelemetry = (metadata: unknown): AssistantRequestTeleme
...(getFiniteNonNegativeNumber(value.upstreamCost) !== undefined
? { upstreamCost: getFiniteNonNegativeNumber(value.upstreamCost) }
: {}),
...(getFiniteNonNegativeNumber(value.cacheDiscount) !== undefined
? { cacheDiscount: getFiniteNonNegativeNumber(value.cacheDiscount) }
: {}),
...(getFiniteNonNegativeNumber(value.fallbackCount) !== undefined
? { fallbackCount: getFiniteNonNegativeNumber(value.fallbackCount) }
: {}),
...(getFiniteNonNegativeNumber(value.fallbackLatencyMs) !== undefined
? { fallbackLatencyMs: getFiniteNonNegativeNumber(value.fallbackLatencyMs) }
: {}),
...(value.costType === "reported" || value.costType === "estimated" ? { costType: value.costType } : {}),
...(typeof value.finishReason === "string" ? { finishReason: value.finishReason } : {}),
};
@@ -614,8 +614,8 @@ const formatDuration = (milliseconds: number) =>
const formatCost = (cost: number) => {
if (cost === 0) return "$0";
if (cost < 0.0001) return `<$0.0001`;
return `$${cost.toFixed(cost < 0.01 ? 4 : 3)}`;
if (cost < 0.00001) return `<$0.00001`;
return `$${cost.toFixed(cost < 0.001 ? 5 : cost < 0.01 ? 4 : 3)}`;
};
const getContextColor = (percentage: number) => {
@@ -628,9 +628,101 @@ const RequestTelemetry = () => {
const t = useScopedI18n("common.assistant");
const [opened, setOpened] = useState(false);
const metadata = useAuiState((state) => state.message.metadata);
const telemetry = getAssistantTelemetry(metadata);
const usage = getAssistantUsage(metadata);
if (!telemetry) return null;
const messageId = useAuiState((state) => state.message.id);
const threadId = useAuiState((state) => state.threadListItem.remoteId);
const persistedTelemetry = getAssistantTelemetry(metadata);
const persistedUsage = getAssistantUsage(metadata);
const generationQuery = clientApi.assistant.getGenerationTelemetry.useQuery(
{ threadId: threadId ?? "", messageId },
{
enabled: opened && persistedTelemetry?.provider === "openrouter" && Boolean(threadId),
refetchInterval: (query) =>
query.state.data?.complete === false && query.state.dataUpdateCount < 6 ? 1500 : false,
retry: 2,
staleTime: (query) => (query.state.data?.complete ? 5 * 60_000 : 0),
},
);
if (!persistedTelemetry) return null;
const generationById = new Map(
generationQuery.data?.generations.flatMap((generation) =>
generation.generationId ? [[generation.generationId, generation] as const] : [],
) ?? [],
);
const steps = persistedTelemetry.steps.map((step) => ({
...step,
...(step.generationId ? generationById.get(step.generationId) : undefined),
}));
const sumCompleteMetric = (getValue: (step: (typeof steps)[number]) => number | undefined) => {
if (steps.length === 0) return undefined;
const values = steps.map(getValue);
return values.some((value) => value === undefined)
? undefined
: (values as number[]).reduce((sum, value) => sum + value, 0);
};
const providerInputTokens = sumCompleteMetric((step) => step.inputTokens);
const providerOutputTokens = sumCompleteMetric((step) => step.outputTokens);
const providerGenerationTimeMs = sumCompleteMetric((step) => step.generationTimeMs);
const providerOutputTokensPerSecond =
providerOutputTokens !== undefined && providerGenerationTimeMs !== undefined && providerGenerationTimeMs > 0
? providerOutputTokens / (providerGenerationTimeMs / 1000)
: undefined;
const latestStep = steps.at(-1);
const providerContextUsed =
latestStep?.inputTokens !== undefined && latestStep.outputTokens !== undefined
? latestStep.inputTokens + latestStep.outputTokens
: undefined;
const usage = {
...persistedUsage,
...(generationQuery.data?.complete && providerInputTokens !== undefined
? { inputTokens: providerInputTokens }
: {}),
...(generationQuery.data?.complete && providerOutputTokens !== undefined
? { outputTokens: providerOutputTokens }
: {}),
...(generationQuery.data?.complete && providerInputTokens !== undefined && providerOutputTokens !== undefined
? { totalTokens: providerInputTokens + providerOutputTokens }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.cachedInputTokens) !== undefined
? { cachedInputTokens: sumCompleteMetric((step) => step.cachedInputTokens) }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.reasoningTokens) !== undefined
? { reasoningTokens: sumCompleteMetric((step) => step.reasoningTokens) }
: {}),
};
const telemetry = {
...persistedTelemetry,
steps,
...(generationQuery.data?.complete && providerGenerationTimeMs !== undefined
? { generationTimeMs: providerGenerationTimeMs }
: {}),
...(generationQuery.data?.complete && providerOutputTokensPerSecond !== undefined
? { providerOutputTokensPerSecond }
: {}),
...(generationQuery.data?.complete && providerContextUsed !== undefined
? {
contextUsed: providerContextUsed,
...(persistedTelemetry.contextLength
? { contextUtilization: Math.min(providerContextUsed / persistedTelemetry.contextLength, 1) }
: {}),
}
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.cost) !== undefined
? { cost: sumCompleteMetric((step) => step.cost), costType: "reported" as const }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.upstreamCost) !== undefined
? { upstreamCost: sumCompleteMetric((step) => step.upstreamCost) }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.cacheDiscount) !== undefined
? { cacheDiscount: sumCompleteMetric((step) => step.cacheDiscount) }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.fallbackCount) !== undefined
? { fallbackCount: sumCompleteMetric((step) => step.fallbackCount) }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.fallbackLatencyMs) !== undefined
? { fallbackLatencyMs: sumCompleteMetric((step) => step.fallbackLatencyMs) }
: {}),
};
const contextLength = telemetry.contextLength ?? 0;
const contextUsed = telemetry.contextUsed ?? 0;
@@ -645,6 +737,21 @@ const RequestTelemetry = () => {
const contextLabel = hasContextWindow
? `${t("usage.contextWindow")}: ${contextUsed.toLocaleString()} / ${contextLength.toLocaleString()} (${contextPercentage}%)`
: `${t("usage.contextWindow")}: ${t("usage.notReported")}`;
const displayedThroughput = telemetry.providerOutputTokensPerSecond ?? telemetry.outputTokensPerSecond;
const hasProviderMetrics =
generationQuery.isFetching ||
telemetry.providerOutputTokensPerSecond !== undefined ||
telemetry.generationTimeMs !== undefined ||
telemetry.cacheDiscount !== undefined ||
telemetry.fallbackCount !== undefined ||
telemetry.fallbackLatencyMs !== undefined ||
telemetry.steps.some(
(step) =>
step.providerLatencyMs !== undefined ||
step.moderationLatencyMs !== undefined ||
step.serviceTier !== undefined ||
step.dataRegion !== undefined,
);
return (
<Box className={classes.telemetry}>
@@ -661,9 +768,9 @@ const RequestTelemetry = () => {
{formatDuration(telemetry.durationMs)}
</Text>
)}
{telemetry.outputTokensPerSecond !== undefined && (
{displayedThroughput !== undefined && (
<Text size="xs" c="dimmed">
{telemetry.outputTokensPerSecond.toFixed(1)} tok/s
{displayedThroughput.toFixed(1)} tok/s
</Text>
)}
{telemetry.cost !== undefined && (
@@ -676,7 +783,7 @@ const RequestTelemetry = () => {
<Popover
opened={opened}
onChange={setOpened}
width="min(20rem, calc(100vw - 1.5rem))"
width="min(23rem, calc(100vw - 1.5rem))"
position="bottom-end"
shadow="md"
withArrow
@@ -803,7 +910,18 @@ const RequestTelemetry = () => {
</Box>
<Divider />
<Group gap="lg" align="flex-start">
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
{t("usage.homarrTiming")}
</Text>
<Box className={classes.usageGrid}>
<div>
<Text size="xs" c="dimmed">
{t("usage.endToEnd")}
</Text>
<Text size="sm" fw={600}>
{telemetry.durationMs !== undefined ? formatDuration(telemetry.durationMs) : t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.firstOutput")}
@@ -814,6 +932,16 @@ const RequestTelemetry = () => {
: t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.observedThroughput")}
</Text>
<Text size="sm" fw={600}>
{telemetry.outputTokensPerSecond !== undefined
? `${telemetry.outputTokensPerSecond.toFixed(1)} tok/s`
: t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.cost")}
@@ -847,7 +975,74 @@ const RequestTelemetry = () => {
</Text>
</div>
)}
</Group>
</Box>
{hasProviderMetrics && (
<>
<Divider />
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
{t("usage.providerTiming")}
</Text>
{generationQuery.isFetching && generationQuery.data?.complete !== true && (
<Group gap="xs">
<Loader size="xs" type="bars" />
<Text size="xs" c="dimmed">
{t("usage.loadingProviderDetails")}
</Text>
</Group>
)}
<Box className={classes.usageGrid}>
<div>
<Text size="xs" c="dimmed">
{t("usage.generationTime")}
</Text>
<Text size="sm" fw={600}>
{telemetry.generationTimeMs !== undefined
? formatDuration(telemetry.generationTimeMs)
: t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.providerThroughput")}
</Text>
<Text size="sm" fw={600}>
{telemetry.providerOutputTokensPerSecond !== undefined
? `${telemetry.providerOutputTokensPerSecond.toFixed(1)} tok/s`
: t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.fallbacks")}
</Text>
<Text size="sm" fw={600}>
{telemetry.fallbackCount?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
{telemetry.fallbackLatencyMs !== undefined && (
<div>
<Text size="xs" c="dimmed">
{t("usage.fallbackLatency")}
</Text>
<Text size="sm" fw={600}>
{formatDuration(telemetry.fallbackLatencyMs)}
</Text>
</div>
)}
{telemetry.cacheDiscount !== undefined && (
<div>
<Text size="xs" c="dimmed">
{t("usage.cacheDiscount")}
</Text>
<Text size="sm" fw={600}>
{formatCost(telemetry.cacheDiscount)}
</Text>
</div>
)}
</Box>
</>
)}
{telemetry.steps.length > 0 && (
<Stack gap={4}>
@@ -863,12 +1058,86 @@ const RequestTelemetry = () => {
</Text>
<Text size="xs" c="dimmed">
{formatDuration(step.durationMs)}
{step.outputTokensPerSecond !== undefined
? ` · ${step.outputTokensPerSecond.toFixed(1)} tok/s`
: ""}
{step.providerOutputTokensPerSecond !== undefined
? ` · ${step.providerOutputTokensPerSecond.toFixed(1)} tok/s`
: step.outputTokensPerSecond !== undefined
? ` · ${step.outputTokensPerSecond.toFixed(1)} tok/s`
: ""}
{step.cost !== undefined ? ` · ${formatCost(step.cost)}` : ""}
</Text>
</Group>
{(step.providerLatencyMs !== undefined ||
step.generationTimeMs !== undefined ||
step.moderationLatencyMs !== undefined ||
step.fallbackCount !== undefined ||
step.fallbackLatencyMs !== undefined) && (
<Text size="xs" c="dimmed">
{[
step.providerLatencyMs !== undefined
? `${t("usage.providerLatency")} ${formatDuration(step.providerLatencyMs)}`
: undefined,
step.generationTimeMs !== undefined
? `${t("usage.generationTime")} ${formatDuration(step.generationTimeMs)}`
: undefined,
step.moderationLatencyMs !== undefined
? `${t("usage.moderationLatency")} ${formatDuration(step.moderationLatencyMs)}`
: undefined,
step.fallbackCount !== undefined
? `${t("usage.fallbacks")} ${step.fallbackCount.toLocaleString()}`
: undefined,
step.fallbackLatencyMs !== undefined
? `${t("usage.fallbackLatency")} ${formatDuration(step.fallbackLatencyMs)}`
: undefined,
]
.filter(Boolean)
.join(" · ")}
</Text>
)}
{(step.inputTokens !== undefined || step.outputTokens !== undefined) && (
<Text size="xs" c="dimmed">
{[
step.inputTokens !== undefined
? `${step.inputTokens.toLocaleString()} ${t("usage.inputShort")}`
: undefined,
step.outputTokens !== undefined
? `${step.outputTokens.toLocaleString()} ${t("usage.outputShort")}`
: undefined,
step.cachedInputTokens !== undefined
? `${step.cachedInputTokens.toLocaleString()} ${t("usage.cachedShort")}`
: undefined,
step.reasoningTokens !== undefined
? `${step.reasoningTokens.toLocaleString()} ${t("usage.reasoningShort")}`
: undefined,
]
.filter(Boolean)
.join(" · ")}
</Text>
)}
{(step.serviceTier ||
step.dataRegion ||
step.routerStrategy ||
step.routerRegion ||
step.isByok) && (
<Text size="xs" c="dimmed">
{[
step.routerStrategy,
step.routerRegion,
step.serviceTier,
step.dataRegion,
step.isByok ? t("usage.byok") : undefined,
]
.filter(Boolean)
.join(" · ")}
</Text>
)}
{(step.normalizedInputTokens !== undefined || step.normalizedOutputTokens !== undefined) &&
(step.normalizedInputTokens !== step.inputTokens ||
step.normalizedOutputTokens !== step.outputTokens) && (
<Text size="xs" c="dimmed">
{t("usage.normalizedTokens")}: {step.normalizedInputTokens?.toLocaleString() ?? "–"} /{" "}
{step.normalizedOutputTokens?.toLocaleString() ?? "–"}
</Text>
)}
{step.generationId && (
<Text size="xs" c="dimmed" className={classes.generationId} title={step.generationId}>
{t("usage.generation")}: {step.generationId}
@@ -0,0 +1,140 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
export type OpenRouterGenerationTelemetry = {
generationId?: string;
routedProvider?: string;
providerLatencyMs?: number;
generationTimeMs?: number;
moderationLatencyMs?: number;
providerOutputTokensPerSecond?: number;
inputTokens?: number;
outputTokens?: number;
normalizedInputTokens?: number;
normalizedOutputTokens?: number;
cachedInputTokens?: number;
reasoningTokens?: number;
cost?: number;
upstreamCost?: number;
cacheDiscount?: number;
finishReason?: string;
nativeFinishReason?: string;
serviceTier?: string;
dataRegion?: string;
fallbackCount?: number;
fallbackLatencyMs?: number;
isByok?: boolean;
streamed?: boolean;
cancelled?: boolean;
};
const asRecord = (value: unknown): Record<string, unknown> | null =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
const asFiniteNonNegativeNumber = (value: unknown) => {
const number = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
return Number.isFinite(number) && number >= 0 ? number : undefined;
};
const asString = (value: unknown) => (typeof value === "string" && value.length > 0 ? value : undefined);
const asBoolean = (value: unknown) => (typeof value === "boolean" ? value : undefined);
export const parseOpenRouterGenerationTelemetry = (value: unknown): OpenRouterGenerationTelemetry | null => {
const root = asRecord(value);
const data = asRecord(root?.data) ?? root;
if (!data) return null;
const normalizedInputTokens = asFiniteNonNegativeNumber(data.tokens_prompt);
const normalizedOutputTokens = asFiniteNonNegativeNumber(data.tokens_completion);
const inputTokens = asFiniteNonNegativeNumber(data.native_tokens_prompt) ?? normalizedInputTokens;
const outputTokens = asFiniteNonNegativeNumber(data.native_tokens_completion) ?? normalizedOutputTokens;
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 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
: Math.max(providerResponses.length - 1, 0)
: undefined;
const fallbackLatencies = failedProviderResponses?.map((response) => asFiniteNonNegativeNumber(response.latency));
const fallbackLatencyMs =
fallbackLatencies && fallbackLatencies.length > 0 && fallbackLatencies.every((latency) => latency !== undefined)
? (fallbackLatencies as number[]).reduce((sum, latency) => sum + latency, 0)
: undefined;
const providerOutputTokensPerSecond =
outputTokens !== undefined && generationTimeMs !== undefined && generationTimeMs > 0
? outputTokens / (generationTimeMs / 1000)
: undefined;
const generationId = asString(data.id);
const routedProvider = asString(data.provider_name);
const finishReason = asString(data.finish_reason);
const nativeFinishReason = asString(data.native_finish_reason);
const serviceTier = asString(data.service_tier);
const dataRegion = asString(data.data_region);
const telemetry: OpenRouterGenerationTelemetry = {
...(generationId ? { generationId } : {}),
...(routedProvider ? { routedProvider } : {}),
...(asFiniteNonNegativeNumber(data.latency) !== undefined
? { providerLatencyMs: asFiniteNonNegativeNumber(data.latency) }
: {}),
...(generationTimeMs !== undefined ? { generationTimeMs } : {}),
...(asFiniteNonNegativeNumber(data.moderation_latency) !== undefined
? { moderationLatencyMs: asFiniteNonNegativeNumber(data.moderation_latency) }
: {}),
...(providerOutputTokensPerSecond !== undefined ? { providerOutputTokensPerSecond } : {}),
...(inputTokens !== undefined ? { inputTokens } : {}),
...(outputTokens !== undefined ? { outputTokens } : {}),
...(normalizedInputTokens !== undefined ? { normalizedInputTokens } : {}),
...(normalizedOutputTokens !== undefined ? { normalizedOutputTokens } : {}),
...(asFiniteNonNegativeNumber(data.native_tokens_cached) !== undefined
? { cachedInputTokens: asFiniteNonNegativeNumber(data.native_tokens_cached) }
: {}),
...(asFiniteNonNegativeNumber(data.native_tokens_reasoning) !== undefined
? { reasoningTokens: asFiniteNonNegativeNumber(data.native_tokens_reasoning) }
: {}),
...(asFiniteNonNegativeNumber(data.total_cost) !== undefined
? { cost: asFiniteNonNegativeNumber(data.total_cost) }
: {}),
...(asFiniteNonNegativeNumber(data.upstream_inference_cost) !== undefined
? { upstreamCost: asFiniteNonNegativeNumber(data.upstream_inference_cost) }
: {}),
...(asFiniteNonNegativeNumber(data.cache_discount) !== undefined
? { cacheDiscount: asFiniteNonNegativeNumber(data.cache_discount) }
: {}),
...(finishReason ? { finishReason } : {}),
...(nativeFinishReason ? { nativeFinishReason } : {}),
...(serviceTier ? { serviceTier } : {}),
...(dataRegion ? { dataRegion } : {}),
...(fallbackCount !== undefined ? { fallbackCount } : {}),
...(fallbackLatencyMs !== undefined ? { fallbackLatencyMs } : {}),
...(asBoolean(data.is_byok) !== undefined ? { isByok: asBoolean(data.is_byok) } : {}),
...(asBoolean(data.streamed) !== undefined ? { streamed: asBoolean(data.streamed) } : {}),
...(asBoolean(data.cancelled) !== undefined ? { cancelled: asBoolean(data.cancelled) } : {}),
};
return Object.keys(telemetry).length > 0 ? telemetry : null;
};
export const fetchOpenRouterGenerationTelemetryAsync = async ({
baseUrl,
generationId,
headers,
}: {
baseUrl: string;
generationId: string;
headers: Record<string, string>;
}): Promise<OpenRouterGenerationTelemetry | null> => {
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());
} catch {
return null;
}
};
+1
View File
@@ -4,3 +4,4 @@ export {
getSelectedModelDetailsAsync,
type AssistantContextEntity,
} from "./router/assistant";
export type { OpenRouterGenerationTelemetry } from "./assistant-generation-telemetry";
+58 -1
View File
@@ -25,8 +25,9 @@ import {
resolveAssistantModelId,
} from "@homarr/definitions";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
import type { createTRPCContext } from "../trpc";
import { fetchOpenRouterGenerationTelemetryAsync } from "../assistant-generation-telemetry";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
import { boardRouter } from "./board";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
@@ -159,6 +160,25 @@ const getProviderHeaders = (configuration: AssistantConfiguration) => {
return { ...headers, ...decryptCustomHeaders(configuration.encryptedHeaders) };
};
const getGenerationIdsFromMessageContent = (content: unknown) => {
if (!content || typeof content !== "object" || Array.isArray(content)) return [];
const metadata = (content as { metadata?: unknown }).metadata;
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return [];
const custom = (metadata as { custom?: unknown }).custom;
if (!custom || typeof custom !== "object" || Array.isArray(custom)) return [];
const telemetry = (custom as { telemetry?: unknown }).telemetry;
if (!telemetry || typeof telemetry !== "object" || Array.isArray(telemetry)) return [];
const steps = (telemetry as { steps?: unknown }).steps;
if (!Array.isArray(steps)) return [];
return steps
.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] : [];
})
.slice(0, 8);
};
const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
if (!configuration.modelDiscoveryPath) {
throw new TRPCError({
@@ -674,6 +694,43 @@ export const assistantRouter = createTRPCRouter({
};
}),
getGenerationTelemetry: protectedProcedure
.input(
z.object({
threadId: z.string().max(64),
messageId: z.string().min(1).max(128),
}),
)
.query(async ({ ctx, input }) => {
const thread = await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
const [message, configuration] = await Promise.all([
ctx.db.query.assistantMessages.findFirst({
where: and(eq(assistantMessages.id, input.messageId), eq(assistantMessages.threadId, thread.id)),
}),
getConfigurationAsync(ctx.db),
]);
if (!message) throw new TRPCError({ code: "NOT_FOUND", message: "Message not found." });
if (configuration?.provider !== "openrouter" || !configuration.encryptedApiKey) {
return { complete: true, generations: [] };
}
const generationIds = getGenerationIdsFromMessageContent(parse(message.content));
if (generationIds.length === 0) return { complete: true, generations: [] };
const generations = await Promise.all(
generationIds.map((generationId) =>
fetchOpenRouterGenerationTelemetryAsync({
baseUrl: configuration.baseUrl,
generationId,
headers: getProviderHeaders(configuration),
}),
),
);
return {
complete: generations.every((generation) => generation !== null),
generations: generations.filter((generation) => generation !== null),
};
}),
renameThread: protectedProcedure
.input(z.object({ threadId: z.string().max(64), title: z.string().trim().min(1).max(120) }))
.mutation(async ({ ctx, input }) => {
@@ -0,0 +1,66 @@
import { describe, expect, test, vi } from "vitest";
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
import { parseOpenRouterGenerationTelemetry } from "../assistant-generation-telemetry";
describe("parseOpenRouterGenerationTelemetry", () => {
test("uses provider-native tokens for context and throughput", () => {
expect(
parseOpenRouterGenerationTelemetry({
data: {
id: "gen-1",
provider_name: "Alibaba",
latency: 964,
generation_time: 1451,
moderation_latency: 12,
tokens_prompt: 13_343,
tokens_completion: 62,
native_tokens_prompt: 12_777,
native_tokens_completion: 55,
native_tokens_cached: 2048,
native_tokens_reasoning: 7,
total_cost: 0.00041,
upstream_inference_cost: 0.00039,
cache_discount: 0.00003,
finish_reason: "stop",
native_finish_reason: "stop",
service_tier: "default",
data_region: "US",
provider_responses: [
{ status: 429, latency: 620 },
{ status: 200, latency: 964 },
],
is_byok: false,
streamed: true,
cancelled: false,
},
}),
).toMatchObject({
generationId: "gen-1",
routedProvider: "Alibaba",
providerLatencyMs: 964,
generationTimeMs: 1451,
providerOutputTokensPerSecond: 37.90489317711923,
inputTokens: 12_777,
outputTokens: 55,
normalizedInputTokens: 13_343,
normalizedOutputTokens: 62,
cachedInputTokens: 2048,
reasoningTokens: 7,
cost: 0.00041,
upstreamCost: 0.00039,
cacheDiscount: 0.00003,
fallbackCount: 1,
fallbackLatencyMs: 620,
serviceTier: "default",
dataRegion: "US",
});
});
test("returns null for malformed generation data", () => {
expect(parseOpenRouterGenerationTelemetry(null)).toBeNull();
expect(parseOpenRouterGenerationTelemetry({ data: [] })).toBeNull();
expect(parseOpenRouterGenerationTelemetry({ data: { latency: -1, total_cost: "invalid" } })).toBeNull();
});
});
+18
View File
@@ -1616,7 +1616,25 @@
"cached": "Cached input",
"cacheWrite": "Cache write",
"reasoning": "Reasoning",
"inputShort": "in",
"outputShort": "out",
"cachedShort": "cached",
"reasoningShort": "reasoning",
"firstOutput": "First output",
"homarrTiming": "Homarr observed",
"endToEnd": "End to end",
"observedThroughput": "Observed throughput",
"providerTiming": "Provider reported",
"loadingProviderDetails": "Loading generation details…",
"providerLatency": "Provider latency",
"generationTime": "Generation time",
"moderationLatency": "Moderation",
"providerThroughput": "Provider throughput",
"fallbacks": "Fallbacks",
"fallbackLatency": "Fallback latency",
"cacheDiscount": "Cache discount",
"normalizedTokens": "OpenRouter normalized input / output",
"byok": "BYOK",
"cost": "Cost",
"reported": "Reported by provider",
"estimated": "Estimated from model pricing",