feat: enrich assistant request telemetry
This commit is contained in:
1 parent
d562706c8b
commit
04520cf67c
10 files changed
+798
-39
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -4,3 +4,4 @@ export {
|
||||
getSelectedModelDetailsAsync,
|
||||
type AssistantContextEntity,
|
||||
} from "./router/assistant";
|
||||
export type { OpenRouterGenerationTelemetry } from "./assistant-generation-telemetry";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user