Files
2026-07-13 13:32:57 +08:00

835 lines
26 KiB
TypeScript

import type { ClickHouse } from "@internal/clickhouse";
import { modelCatalog } from "@internal/llm-model-catalog";
import type { PrismaClientOrTransaction } from "~/db.server";
import { BasePresenter } from "./basePresenter.server";
import { z } from "zod";
/** Format a Date for ClickHouse DateTime64 string params. */
function formatDateForCH(date: Date): string {
return date
.toISOString()
.replace("T", " ")
.replace(/\.\d{3}Z$/, "");
}
// --- Helpers ---
/** Infer provider from model name when not stored in the DB. */
function inferProvider(modelName: string): string {
const lower = modelName.toLowerCase();
// OpenAI
if (
/^(gpt-|o[1-9]|chatgpt|davinci|babbage|curie|ada|text-embedding|text-davinci|text-ada|text-babbage|text-curie|ft:)/.test(
lower
)
)
return "openai";
// Anthropic
if (lower.startsWith("claude-")) return "anthropic";
// Google
if (
/^(gemini-|palm-|text-bison|chat-bison|code-bison|codechat-bison|text-unicorn|textembedding-gecko)/.test(
lower
)
)
return "google";
// Meta
if (/^(llama|code-llama|codellama)/.test(lower)) return "meta";
// Mistral
if (/^(mistral|mixtral|codestral|pixtral|ministral)/.test(lower)) return "mistral";
// xAI
if (lower.startsWith("grok")) return "xai";
// DeepSeek
if (lower.startsWith("deepseek")) return "deepseek";
// Cohere
if (/^(command|embed-|rerank-)/.test(lower)) return "cohere";
// AI21
if (/^(jamba|j2-)/.test(lower)) return "ai21";
// Amazon
if (/^(amazon\.|titan)/.test(lower)) return "amazon";
// Qwen (Alibaba)
if (lower.startsWith("qwen")) return "qwen";
// Perplexity
if (/^(pplx-|sonar-)/.test(lower)) return "perplexity";
// Nous
if (lower.startsWith("nous-")) return "nous";
// Provider prefix format: "provider/model" (e.g. "openai/gpt-4o")
if (lower.includes("/")) {
return lower.split("/")[0];
}
return "unknown";
}
/** Format a model as provider:name (e.g. "openai:gpt-5"). */
export function formatModelId(provider: string, modelName: string): string {
return `${provider}:${modelName}`;
}
/**
* Hardcoded provider display priority (most relevant first). Providers not in
* this list fall back to alphabetical order after the listed ones. Within a
* provider, models are always sorted by release date (newest first).
*/
const PROVIDER_IMPORTANCE = ["anthropic", "openai", "google", "xai", "meta", "mistral", "deepseek"];
function providerRank(provider: string): number {
const index = PROVIDER_IMPORTANCE.indexOf(provider);
return index === -1 ? PROVIDER_IMPORTANCE.length : index;
}
/**
* Pick a sparkline bucket size (in seconds) for a given range so the rendered
* sparkline stays a readable ~24-52 bars. Tuned for the small inline charts in
* the "Your models" list — coarser than the full-size dashboard charts.
*/
function sparklineBucketSeconds(rangeMs: number): number {
const MIN = 60;
const HOUR = 3600;
const DAY = 86400;
const ms = (s: number) => s * 1000;
if (rangeMs <= ms(HOUR)) return 2 * MIN;
if (rangeMs <= ms(3 * HOUR)) return 5 * MIN;
if (rangeMs <= ms(6 * HOUR)) return 15 * MIN;
if (rangeMs <= ms(DAY)) return HOUR;
if (rangeMs <= ms(3 * DAY)) return 2 * HOUR;
if (rangeMs <= ms(7 * DAY)) return 6 * HOUR;
if (rangeMs <= ms(14 * DAY)) return 12 * HOUR;
if (rangeMs <= ms(30 * DAY)) return DAY;
if (rangeMs <= ms(90 * DAY)) return 3 * DAY;
return 7 * DAY;
}
/**
* Generate the ordered bucket-start keys for [from, to] at the given interval,
* as epoch seconds to match ClickHouse's
* `toUnixTimestamp(toStartOfInterval(col, INTERVAL n SECOND))` — timezone-independent
* (a raw DateTime string would depend on the ClickHouse server timezone).
*/
function sparklineBucketKeys(from: Date, to: Date, intervalSeconds: number): number[] {
const intervalMs = intervalSeconds * 1000;
const start = Math.floor(from.getTime() / intervalMs) * intervalMs;
const end = Math.floor(to.getTime() / intervalMs) * intervalMs;
const keys: number[] = [];
for (let t = start; t <= end; t += intervalMs) {
keys.push(t / 1000);
}
return keys;
}
// --- Types ---
export type ModelCatalogItem = {
friendlyId: string;
modelName: string;
/** Always resolved — from DB, inferred from name, or "unknown". */
provider: string;
/** Display identifier in provider:name format (e.g. "openai:gpt-5"). */
displayId: string;
description: string | null;
contextWindow: number | null;
maxOutputTokens: number | null;
/** Combined capabilities (from DB) and boolean feature flags (from catalog) as slug strings. */
features: string[];
inputPrice: number | null;
outputPrice: number | null;
/** When the model was publicly released (from startDate on LlmModel). */
releaseDate: string | null;
/** Dated variants of this model (only populated on base models). */
variants: ModelVariant[];
};
export type ModelVariant = {
friendlyId: string;
modelName: string;
displayId: string;
releaseDate: string | null;
};
export type ModelCatalogGroup = {
provider: string;
models: ModelCatalogItem[];
};
export type ModelDetail = ModelCatalogItem & {
matchPattern: string;
source: string;
pricingTiers: Array<{
name: string;
isDefault: boolean;
prices: Record<string, number>;
}>;
};
function buildFeatures(
capabilities: string[],
catalogEntry:
| {
supportsStructuredOutput: boolean;
supportsParallelToolCalls: boolean;
supportsStreamingToolCalls: boolean;
}
| undefined
): string[] {
const features = new Set(capabilities);
if (catalogEntry?.supportsStructuredOutput) features.add("structured_output");
if (catalogEntry?.supportsParallelToolCalls) features.add("parallel_tool_calls");
if (catalogEntry?.supportsStreamingToolCalls) features.add("streaming_tool_calls");
return Array.from(features);
}
export type ModelMetricsPoint = {
minute: string;
callCount: number;
totalInputTokens: number;
totalOutputTokens: number;
totalCost: number;
ttfcP50: number;
ttfcP90: number;
ttfcP95: number;
ttfcP99: number;
tpsP50: number;
tpsP90: number;
tpsP95: number;
tpsP99: number;
durationP50: number;
durationP90: number;
durationP95: number;
durationP99: number;
};
export type UserModelMetrics = {
totalCalls: number;
totalCost: number;
totalInputTokens: number;
totalOutputTokens: number;
avgTtfc: number;
avgTps: number;
taskBreakdown: Array<{
taskIdentifier: string;
calls: number;
cost: number;
}>;
};
export type ModelComparisonItem = {
responseModel: string;
genAiSystem: string;
callCount: number;
totalInputTokens: number;
totalOutputTokens: number;
totalCost: number;
ttfcP50: number;
ttfcP90: number;
tpsP50: number;
tpsP90: number;
};
export type PopularModel = {
responseModel: string;
genAiSystem: string;
callCount: number;
totalCost: number;
ttfcP50: number;
};
/** A model with usage in a specific project/environment (the "Your models" list). */
export type ProjectModelUsageItem = {
responseModel: string;
genAiSystem: string;
calls: number;
totalCost: number;
totalTokens: number;
avgTtfc: number;
avgTps: number;
/** Input tokens (used as the denominator for the cache read rate). */
inputTokens: number;
/** Input tokens served from the provider's prompt cache. */
cachedReadTokens: number;
/** Actual (discounted) cost of those cached read tokens. */
cachedReadCost: number;
};
// --- ClickHouse schemas for user metrics ---
const UserMetricsSummaryRow = z.object({
total_calls: z.coerce.number(),
total_cost: z.coerce.number(),
total_input_tokens: z.coerce.number(),
total_output_tokens: z.coerce.number(),
avg_ttfc: z.coerce.number(),
avg_tps: z.coerce.number(),
});
const UserTaskBreakdownRow = z.object({
task_identifier: z.string(),
calls: z.coerce.number(),
cost: z.coerce.number(),
});
const ProjectModelUsageRow = z.object({
response_model: z.string(),
gen_ai_system: z.string(),
calls: z.coerce.number(),
total_cost: z.coerce.number(),
total_tokens: z.coerce.number(),
avg_ttfc: z.coerce.number(),
avg_tps: z.coerce.number(),
input_tokens: z.coerce.number(),
cached_read_tokens: z.coerce.number(),
cached_read_cost: z.coerce.number(),
});
const ModelSparklineRow = z.object({
response_model: z.string(),
bucket: z.coerce.number(),
val: z.coerce.number(),
});
// --- Presenter ---
export class ModelRegistryPresenter extends BasePresenter {
private readonly clickhouse: ClickHouse;
constructor(clickhouse: ClickHouse, replica?: PrismaClientOrTransaction) {
super(undefined, replica);
this.clickhouse = clickhouse;
}
/** List all visible global models with pricing, grouped by provider. */
async getModelCatalog(): Promise<ModelCatalogGroup[]> {
const models = await this._replica.llmModel.findMany({
where: {
projectId: null,
isHidden: false,
},
include: {
pricingTiers: {
where: { isDefault: true },
include: { prices: true },
take: 1,
},
},
orderBy: { modelName: "asc" },
});
type CatalogItemWithBase = ModelCatalogItem & { _baseModelName: string | null };
const items: CatalogItemWithBase[] = models.map((m) => {
const defaultTier = m.pricingTiers[0];
const prices = defaultTier?.prices ?? [];
const inputPrice = prices.find((p) => p.usageType === "input");
const outputPrice = prices.find((p) => p.usageType === "output");
const provider = m.provider ?? inferProvider(m.modelName);
const catalogEntry = modelCatalog[m.modelName];
return {
friendlyId: m.friendlyId,
modelName: m.modelName,
provider,
displayId: formatModelId(provider, m.modelName),
description: m.description,
contextWindow: m.contextWindow,
maxOutputTokens: m.maxOutputTokens,
features: buildFeatures(m.capabilities, catalogEntry),
inputPrice: inputPrice ? Number(inputPrice.price) : null,
outputPrice: outputPrice ? Number(outputPrice.price) : null,
releaseDate: m.startDate ? m.startDate.toISOString().split("T")[0] : null,
variants: [],
_baseModelName: m.baseModelName,
};
});
// Normalize version dots for grouping: "3.5" → "3-5", "4.1" → "4-1"
const normalizeForGrouping = (name: string) => name.replace(/(\d)\.(\d)/g, "$1-$2");
// Group variants by their normalized base model name
const variantGroups = new Map<string, CatalogItemWithBase[]>();
for (const item of items) {
const groupKey = normalizeForGrouping(item._baseModelName ?? item.modelName);
const group = variantGroups.get(groupKey) ?? [];
group.push(item);
variantGroups.set(groupKey, group);
}
// For each group, pick the best representative as the "card" model
// and nest the rest as variants
const baseModels: ModelCatalogItem[] = [];
for (const [_groupKey, group] of variantGroups) {
if (group.length === 1) {
// Standalone model, no variants
baseModels.push(group[0]);
continue;
}
// Pick representative: prefer the actual base model (no _baseModelName),
// then "-latest" variant, then the newest by release date
let representative =
group.find((m) => !m._baseModelName) ??
group.find((m) => m.modelName.endsWith("-latest")) ??
group.sort((a, b) => {
if (!a.releaseDate && !b.releaseDate) return 0;
if (!a.releaseDate) return 1;
if (!b.releaseDate) return -1;
return b.releaseDate.localeCompare(a.releaseDate);
})[0];
// Nest the others as variants, sorted newest first
const others = group
.filter((m) => m !== representative)
.sort((a, b) => {
if (!a.releaseDate && !b.releaseDate) return a.modelName.localeCompare(b.modelName);
if (!a.releaseDate) return 1;
if (!b.releaseDate) return -1;
return b.releaseDate.localeCompare(a.releaseDate);
});
representative.variants = others.map((m) => ({
friendlyId: m.friendlyId,
modelName: m.modelName,
displayId: m.displayId,
releaseDate: m.releaseDate,
}));
baseModels.push(representative);
}
// Group by provider, sort models within each group by release date (newest first)
const groups = new Map<string, ModelCatalogItem[]>();
for (const item of baseModels) {
const group = groups.get(item.provider) ?? [];
group.push(item);
groups.set(item.provider, group);
}
return Array.from(groups.entries())
.sort(([a], [b]) => {
const rankA = providerRank(a);
const rankB = providerRank(b);
if (rankA !== rankB) return rankA - rankB;
return a.localeCompare(b);
})
.map(([provider, models]) => ({
provider,
models: models.sort((a, b) => {
if (!a.releaseDate && !b.releaseDate) return a.modelName.localeCompare(b.modelName);
if (!a.releaseDate) return 1;
if (!b.releaseDate) return -1;
return b.releaseDate.localeCompare(a.releaseDate);
}),
}));
}
/** Get a single model with full pricing details. */
async getModelDetail(friendlyId: string): Promise<ModelDetail | null> {
const model = await this._replica.llmModel.findFirst({
where: { friendlyId },
include: {
pricingTiers: {
include: { prices: true },
orderBy: { priority: "asc" },
},
},
});
if (!model) return null;
const defaultTier = model.pricingTiers.find((t) => t.isDefault) ?? model.pricingTiers[0];
const defaultPrices = defaultTier?.prices ?? [];
const inputPrice = defaultPrices.find((p) => p.usageType === "input");
const outputPrice = defaultPrices.find((p) => p.usageType === "output");
const provider = model.provider ?? inferProvider(model.modelName);
const catalogEntry = modelCatalog[model.modelName];
return {
friendlyId: model.friendlyId,
modelName: model.modelName,
provider,
displayId: formatModelId(provider, model.modelName),
description: model.description,
contextWindow: model.contextWindow,
maxOutputTokens: model.maxOutputTokens,
features: buildFeatures(model.capabilities, catalogEntry),
inputPrice: inputPrice ? Number(inputPrice.price) : null,
outputPrice: outputPrice ? Number(outputPrice.price) : null,
releaseDate: model.startDate ? model.startDate.toISOString().split("T")[0] : null,
variants: [],
matchPattern: model.matchPattern,
source: model.source,
pricingTiers: model.pricingTiers.map((t) => ({
name: t.name,
isDefault: t.isDefault,
prices: Object.fromEntries(t.prices.map((p) => [p.usageType, Number(p.price)])),
})),
};
}
/** Get global aggregate metrics for a model (no tenant info). */
async getGlobalMetrics(
responseModel: string,
startTime: Date,
endTime: Date
): Promise<ModelMetricsPoint[]> {
const [error, rows] = await this.clickhouse.llmModelAggregates.globalMetrics
.setParams({
responseModel,
startTime: formatDateForCH(startTime),
endTime: formatDateForCH(endTime),
})
.execute();
if (error || !rows) return [];
return rows.map((r) => ({
minute: r.minute,
callCount: r.call_count,
totalInputTokens: r.total_input_tokens,
totalOutputTokens: r.total_output_tokens,
totalCost: r.total_cost,
ttfcP50: r.ttfc_p50,
ttfcP90: r.ttfc_p90,
ttfcP95: r.ttfc_p95,
ttfcP99: r.ttfc_p99,
tpsP50: r.tps_p50,
tpsP90: r.tps_p90,
tpsP95: 0,
tpsP99: 0,
durationP50: r.duration_p50,
durationP90: r.duration_p90,
durationP95: 0,
durationP99: 0,
}));
}
/** Get per-project usage metrics for a model. */
async getUserMetrics(
responseModel: string,
projectId: string,
environmentId: string,
startTime: Date,
endTime: Date
): Promise<UserModelMetrics> {
const summaryQuery = this.clickhouse.reader.query({
name: "modelRegistryUserSummary",
query: `
SELECT
count() AS total_calls,
sum(total_cost) AS total_cost,
sum(input_tokens) AS total_input_tokens,
sum(output_tokens) AS total_output_tokens,
round(avg(ms_to_first_chunk), 1) AS avg_ttfc,
round(avg(tokens_per_second), 1) AS avg_tps
FROM trigger_dev.llm_metrics_v1
WHERE response_model = {responseModel: String}
AND project_id = {projectId: String}
AND environment_id = {environmentId: String}
AND start_time >= {startTime: String}
AND start_time <= {endTime: String}
`,
params: z.object({
responseModel: z.string(),
projectId: z.string(),
environmentId: z.string(),
startTime: z.string(),
endTime: z.string(),
}),
schema: UserMetricsSummaryRow,
});
const taskQuery = this.clickhouse.reader.query({
name: "modelRegistryUserTasks",
query: `
SELECT
task_identifier,
count() AS calls,
sum(total_cost) AS cost
FROM trigger_dev.llm_metrics_v1
WHERE response_model = {responseModel: String}
AND project_id = {projectId: String}
AND environment_id = {environmentId: String}
AND start_time >= {startTime: String}
AND start_time <= {endTime: String}
GROUP BY task_identifier
ORDER BY cost DESC
LIMIT 20
`,
params: z.object({
responseModel: z.string(),
projectId: z.string(),
environmentId: z.string(),
startTime: z.string(),
endTime: z.string(),
}),
schema: UserTaskBreakdownRow,
});
const queryParams = {
responseModel,
projectId,
environmentId,
startTime: formatDateForCH(startTime),
endTime: formatDateForCH(endTime),
};
const [summaryResult, taskResult] = await Promise.all([
summaryQuery(queryParams),
taskQuery(queryParams),
]);
const [summaryError, summaryRows] = summaryResult;
const [taskError, taskRows] = taskResult;
const defaultSummary = {
total_calls: 0,
total_cost: 0,
total_input_tokens: 0,
total_output_tokens: 0,
avg_ttfc: 0,
avg_tps: 0,
};
const summary = !summaryError && summaryRows?.[0] ? summaryRows[0] : defaultSummary;
return {
totalCalls: summary.total_calls,
totalCost: summary.total_cost,
totalInputTokens: summary.total_input_tokens,
totalOutputTokens: summary.total_output_tokens,
avgTtfc: summary.avg_ttfc,
avgTps: summary.avg_tps,
taskBreakdown:
!taskError && taskRows
? taskRows.map((r) => ({
taskIdentifier: r.task_identifier,
calls: r.calls,
cost: r.cost,
}))
: [],
};
}
/** Get comparison data for 2-4 models. */
async getModelComparison(
responseModels: string[],
startTime: Date,
endTime: Date
): Promise<ModelComparisonItem[]> {
const [error, rows] = await this.clickhouse.llmModelAggregates.comparison
.setParams({
responseModels,
startTime: formatDateForCH(startTime),
endTime: formatDateForCH(endTime),
})
.execute();
if (error || !rows) return [];
return rows.map((r) => ({
responseModel: r.response_model,
genAiSystem: r.gen_ai_system,
callCount: r.call_count,
totalInputTokens: r.total_input_tokens,
totalOutputTokens: r.total_output_tokens,
totalCost: r.total_cost,
ttfcP50: r.ttfc_p50,
ttfcP90: r.ttfc_p90,
tpsP50: r.tps_p50,
tpsP90: r.tps_p90,
}));
}
/** Get the most popular models by call count. */
async getPopularModels(
startTime: Date,
endTime: Date,
limit: number = 20
): Promise<PopularModel[]> {
const [error, rows] = await this.clickhouse.llmModelAggregates.popular
.setParams({
startTime: formatDateForCH(startTime),
endTime: formatDateForCH(endTime),
limit,
})
.execute();
if (error || !rows) return [];
return rows.map((r) => ({
responseModel: r.response_model,
genAiSystem: r.gen_ai_system,
callCount: r.call_count,
totalCost: r.total_cost,
ttfcP50: r.ttfc_p50,
}));
}
/**
* Models that had usage in a specific project/environment over the window,
* with aggregate metrics. This is the tenant-scoped "Your models" list (as
* opposed to the cross-tenant getPopularModels).
*/
async getProjectModelUsage(
projectId: string,
environmentId: string,
startTime: Date,
endTime: Date
): Promise<ProjectModelUsageItem[]> {
const queryFn = this.clickhouse.reader.query({
name: "modelRegistryProjectUsage",
query: `
SELECT
response_model,
any(gen_ai_system) AS gen_ai_system,
count() AS calls,
sum(total_cost) AS total_cost,
sum(total_tokens) AS total_tokens,
round(avg(ms_to_first_chunk), 1) AS avg_ttfc,
round(avg(tokens_per_second), 1) AS avg_tps,
sum(input_tokens) AS input_tokens,
sum(usage_details['input_cached_tokens']) AS cached_read_tokens,
sum(cost_details['input_cached_tokens']) AS cached_read_cost
FROM trigger_dev.llm_metrics_v1
WHERE project_id = {projectId: String}
AND environment_id = {environmentId: String}
AND start_time >= {startTime: String}
AND start_time <= {endTime: String}
AND response_model != ''
GROUP BY response_model
ORDER BY calls DESC
LIMIT 100
`,
params: z.object({
projectId: z.string(),
environmentId: z.string(),
startTime: z.string(),
endTime: z.string(),
}),
schema: ProjectModelUsageRow,
});
const [error, rows] = await queryFn({
projectId,
environmentId,
startTime: formatDateForCH(startTime),
endTime: formatDateForCH(endTime),
});
if (error || !rows) return [];
return rows.map((r) => ({
responseModel: r.response_model,
genAiSystem: r.gen_ai_system,
calls: r.calls,
totalCost: r.total_cost,
totalTokens: r.total_tokens,
avgTtfc: r.avg_ttfc,
avgTps: r.avg_tps,
inputTokens: r.input_tokens,
cachedReadTokens: r.cached_read_tokens,
cachedReadCost: r.cached_read_cost,
}));
}
/**
* Call-count and total-token sparklines per response_model over [from, to],
* matching the window the "Your models" charts and table use. The bucket size
* adapts to the range (see sparklineBucketSeconds) so a sparkline stays a
* readable ~24-52 bars regardless of the selected period. Zero-filled.
*/
async getModelUsageSparklines(
projectId: string,
environmentId: string,
responseModels: string[],
from: Date,
to: Date
): Promise<{
calls: Record<string, number[]>;
tokens: Record<string, number[]>;
bucketIntervalMs: number;
bucketStartMs: number;
}> {
const intervalSeconds = sparklineBucketSeconds(to.getTime() - from.getTime());
const intervalMs = intervalSeconds * 1000;
// Epoch-aligned start of the first bucket, matching sparklineBucketKeys and
// ClickHouse toStartOfInterval. Returned so the sparkline tooltip can label
// each bar with its true time rather than assuming hourly buckets.
const bucketStartMs = Math.floor(from.getTime() / intervalMs) * intervalMs;
if (responseModels.length === 0) {
return { calls: {}, tokens: {}, bucketIntervalMs: intervalMs, bucketStartMs };
}
const bucketKeys = sparklineBucketKeys(from, to, intervalSeconds);
// intervalSeconds is a server-derived integer from a fixed ladder, so it's
// safe to inline. Epoch-aligned SECOND buckets match the JS keys above.
const buildQuery = (valueExpr: string, name: string) =>
this.clickhouse.reader.query({
name,
query: `
SELECT
response_model,
toUnixTimestamp(toStartOfInterval(start_time, INTERVAL ${intervalSeconds} SECOND)) AS bucket,
${valueExpr} AS val
FROM trigger_dev.llm_metrics_v1
WHERE project_id = {projectId: String}
AND environment_id = {environmentId: String}
AND response_model IN {responseModels: Array(String)}
AND start_time >= {startTime: String}
AND start_time <= {endTime: String}
GROUP BY response_model, bucket
ORDER BY response_model, bucket
`,
params: z.object({
projectId: z.string(),
environmentId: z.string(),
responseModels: z.array(z.string()),
startTime: z.string(),
endTime: z.string(),
}),
schema: ModelSparklineRow,
});
const queryParams = {
projectId,
environmentId,
responseModels,
startTime: formatDateForCH(from),
endTime: formatDateForCH(to),
};
const [callsResult, tokensResult] = await Promise.all([
buildQuery("count()", "modelCallSparklines")(queryParams),
buildQuery("sum(total_tokens)", "modelTokenSparklines")(queryParams),
]);
return {
calls: this.#buildSparklineMap(callsResult, responseModels, bucketKeys),
tokens: this.#buildSparklineMap(tokensResult, responseModels, bucketKeys),
bucketIntervalMs: intervalMs,
bucketStartMs,
};
}
/** Convert a sparkline query result to a zero-filled bucket map. */
#buildSparklineMap(
queryResult: [Error, null] | [null, { response_model: string; bucket: number; val: number }[]],
keys: string[],
bucketKeys: number[]
): Record<string, number[]> {
const [error, rows] = queryResult;
if (error || !rows) return {};
const rowMap = new Map<string, number>();
for (const row of rows) {
rowMap.set(`${row.response_model}|${row.bucket}`, row.val);
}
const result: Record<string, number[]> = {};
for (const key of keys) {
result[key] = bucketKeys.map((b) => rowMap.get(`${key}|${b}`) ?? 0);
}
return result;
}
}