Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions backend/src/api/admin/dashboards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Elysia, t } from "elysia";
import {
isEnvOverrideActive,
getGrafanaDashboards,
setGrafanaDashboards,
clearGrafanaDashboards,
EnvOverrideError,
} from "@/utils/dashboards";

const dashboardSchema = t.Object({
id: t.String(),
label: t.String(),
url: t.String({ format: "uri" }),
});

const dashboardsArraySchema = t.Array(dashboardSchema);

export const adminDashboards = new Elysia().group("/dashboards", (app) =>
app
.get(
"/",
async () => {
const dashboards = await getGrafanaDashboards();
return {
dashboards,
envOverride: isEnvOverrideActive(),
};
},
{
detail: {
description:
"Get Grafana dashboards configuration. Returns envOverride=true if GRAFANA_DASHBOARDS env var is set.",
},
},
)
.put(
"/",
async ({ body, status }) => {
try {
await setGrafanaDashboards(body.dashboards);
return {
dashboards: body.dashboards,
envOverride: false,
};
} catch (error) {
if (error instanceof EnvOverrideError) {
return status(409, { error: error.message });
}
throw error;
}
},
{
body: t.Object({
dashboards: dashboardsArraySchema,
}),
detail: {
description:
"Update Grafana dashboards configuration. Returns 409 if GRAFANA_DASHBOARDS env var is set.",
},
},
)
.delete(
"/",
async ({ status }) => {
try {
await clearGrafanaDashboards();
return { success: true };
} catch (error) {
if (error instanceof EnvOverrideError) {
return status(409, { error: error.message });
}
throw error;
}
},
{
detail: {
description:
"Clear Grafana dashboards configuration. Returns 409 if GRAFANA_DASHBOARDS env var is set.",
},
},
),
);
4 changes: 4 additions & 0 deletions backend/src/api/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { apiKeyPlugin } from "@/plugins/apiKeyPlugin";
import { COMMIT_SHA } from "@/utils/config";
import { adminApiKey } from "./apiKey";
import { adminCompletions } from "./completions";
import { adminDashboards } from "./dashboards";
import { adminEmbeddings } from "./embeddings";
import { adminModels } from "./models";
import { adminProviders } from "./providers";
import { adminRateLimits } from "./rateLimits";
import { adminSettings } from "./settings";
import { adminStats } from "./stats";
import { adminUpstream } from "./upstream";
import { adminUsage } from "./usage";
Expand All @@ -29,6 +31,8 @@ export const routes = new Elysia({
.use(adminModels)
.use(adminEmbeddings)
.use(adminStats)
.use(adminSettings)
.use(adminDashboards)
.get("/", () => true, {
detail: { description: "Check whether the admin secret is valid." },
})
Expand Down
58 changes: 58 additions & 0 deletions backend/src/api/admin/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Elysia, t } from "elysia";
import { deleteSetting, getAllSettings, getSetting, upsertSetting } from "@/db";

export const adminSettings = new Elysia().group("/settings", (app) =>
app
.get(
"/",
async () => {
return await getAllSettings();
},
{
detail: { description: "List all settings" },
},
)
.get(
"/:key",
async ({ params, status }) => {
const setting = await getSetting(params.key);
if (!setting) {
return status(404, "Setting not found");
}
return setting;
},
{
params: t.Object({ key: t.String() }),
detail: { description: "Get a setting by key" },
},
)
.put(
"/:key",
async ({ params, body }) => {
const result = await upsertSetting({
key: params.key,
value: body.value,
});
return result;
},
{
params: t.Object({ key: t.String() }),
body: t.Object({ value: t.Any() }),
Comment thread
pescn marked this conversation as resolved.
detail: { description: "Create or update a setting" },
},
)
.delete(
"/:key",
async ({ params, status }) => {
const result = await deleteSetting(params.key);
if (!result) {
return status(404, "Setting not found");
}
return result;
},
{
params: t.Object({ key: t.String() }),
detail: { description: "Delete a setting" },
},
),
);
51 changes: 51 additions & 0 deletions backend/src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1534,3 +1534,54 @@ export async function getActiveEntityCounts() {
embeddingModels: Number(row?.embedding_models ?? 0),
};
}

/**
* Get completion cost metrics grouped by model, provider, and api_key_comment
* Calculates costs based on model pricing: (prompt_tokens / 1M) * input_price + (completion_tokens / 1M) * output_price
* Returns all-time totals for Prometheus counters
*/
export async function getCompletionCostMetrics() {
logger.debug("getCompletionCostMetrics");
const result = await db.execute(sql`
SELECT
c.model,
COALESCE(p.name, 'unknown') AS provider,
COALESCE(ak.comment, 'unknown') AS api_key_comment,
COALESCE(SUM(
CASE WHEN c.prompt_tokens > 0 AND m.input_price IS NOT NULL
THEN (c.prompt_tokens::numeric / 1000000) * m.input_price
ELSE 0
END
), 0) AS prompt_cost_usd,
COALESCE(SUM(
CASE WHEN c.completion_tokens > 0 AND m.output_price IS NOT NULL
THEN (c.completion_tokens::numeric / 1000000) * m.output_price
ELSE 0
END
), 0) AS completion_cost_usd,
COALESCE(SUM(
CASE WHEN c.prompt_tokens > 0 AND m.input_price IS NOT NULL
THEN (c.prompt_tokens::numeric / 1000000) * m.input_price
ELSE 0
END +
CASE WHEN c.completion_tokens > 0 AND m.output_price IS NOT NULL
THEN (c.completion_tokens::numeric / 1000000) * m.output_price
ELSE 0
END
), 0) AS total_cost_usd
Comment thread
coderabbitai[bot] marked this conversation as resolved.
FROM completions c
LEFT JOIN models m ON c.model_id = m.id
LEFT JOIN providers p ON m.provider_id = p.id
LEFT JOIN api_keys ak ON c.api_key_id = ak.id
WHERE c.deleted = false
GROUP BY c.model, p.name, ak.comment
`);
return result as unknown as {
model: string;
provider: string;
api_key_comment: string;
prompt_cost_usd: string;
completion_cost_usd: string;
total_cost_usd: string;
}[];
}
61 changes: 61 additions & 0 deletions backend/src/services/prometheus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getEmbeddingDurationHistogram,
getActiveEntityCounts,
getApiKeyRateLimitConfig,
getCompletionCostMetrics,
LATENCY_BUCKETS_MS,
} from "@/db";
import { getRateLimitRejections } from "@/plugins/apiKeyRateLimitPlugin";
Expand Down Expand Up @@ -189,6 +190,7 @@ async function generateMetricsInternal(): Promise<string> {
entityCounts,
apiKeyConfigs,
rateLimitRejections,
costMetrics,
] = await Promise.all([
getCompletionMetricsByModelAndStatus(),
getEmbeddingMetricsByModelAndStatus(),
Expand All @@ -198,6 +200,7 @@ async function generateMetricsInternal(): Promise<string> {
getActiveEntityCounts(),
getApiKeyRateLimitConfig(),
getRateLimitRejections(),
getCompletionCostMetrics(),
]);

const sections: string[] = [];
Expand Down Expand Up @@ -322,6 +325,64 @@ async function generateMetricsInternal(): Promise<string> {
);
}

// Cost counters (USD)
const promptCostValues: MetricValue[] = [];
const completionCostValues: MetricValue[] = [];
const totalCostValues: MetricValue[] = [];

for (const row of costMetrics) {
const labels = {
model: row.model,
provider: row.provider,
api_key_comment: row.api_key_comment,
};

const promptCost = Number(row.prompt_cost_usd);
const completionCost = Number(row.completion_cost_usd);
const totalCost = Number(row.total_cost_usd);

// Only add metrics if there's actual cost data (pricing was configured)
if (promptCost > 0) {
promptCostValues.push({ labels, value: promptCost });
}
if (completionCost > 0) {
completionCostValues.push({ labels, value: completionCost });
}
if (totalCost > 0) {
totalCostValues.push({ labels, value: totalCost });
}
}

if (promptCostValues.length > 0) {
sections.push(
formatCounter(
"nexusgate_cost_prompt_usd_total",
"Total prompt cost in USD (based on model pricing)",
promptCostValues,
),
);
}

if (completionCostValues.length > 0) {
sections.push(
formatCounter(
"nexusgate_cost_completion_usd_total",
"Total completion cost in USD (based on model pricing)",
completionCostValues,
),
);
}

if (totalCostValues.length > 0) {
sections.push(
formatCounter(
"nexusgate_cost_total_usd_total",
"Total cost in USD (prompt + completion, based on model pricing)",
totalCostValues,
),
);
}

// Completion duration histogram
const durationHistValues = parseHistogramData(
completionDurationHist,
Expand Down
15 changes: 15 additions & 0 deletions backend/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,18 @@ export const METRICS_CACHE_TTL_SECONDS = env(
z.coerce.number().int().positive(),
"30",
);

// Grafana dashboards configuration
export const grafanaDashboardSchema = z.object({
id: z.string(),
label: z.string(),
url: z.string().url(),
});

export const grafanaDashboardsSchema = z.array(grafanaDashboardSchema);
export type GrafanaDashboard = z.infer<typeof grafanaDashboardSchema>;

export const GRAFANA_DASHBOARDS = env(
"grafana dashboards",
zObject(grafanaDashboardsSchema.optional()),
);
Loading