@meyicloud/meyi-cost-server 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +197 -0
- package/README.md +454 -0
- package/cur.js +2 -0
- package/index.js +2 -0
- package/package.json +36 -0
- package/src/controllers/budget.controller.js +39 -0
- package/src/controllers/cost-analysis.controller.js +28 -0
- package/src/controllers/cost.controller.js +144 -0
- package/src/cur-discovery/cur-discovery.aws.js +82 -0
- package/src/cur-discovery/cur-discovery.repository.js +177 -0
- package/src/cur-discovery/cur-discovery.service.js +112 -0
- package/src/cur-discovery/cur-discovery.worker.js +57 -0
- package/src/cur-discovery/schema.js +48 -0
- package/src/lib/cost-analysis.js +116 -0
- package/src/lib/cost-utils.js +98 -0
- package/src/lib/llm-provider.js +239 -0
- package/src/models/budget.model.js +26 -0
- package/src/models/cur-data-status.model.js +17 -0
- package/src/models/cur-ingestion.model.js +9 -0
- package/src/models/customer-aws-context.model.js +15 -0
- package/src/models/saas-cur-context.model.js +10 -0
- package/src/plugin.js +82 -0
- package/src/repositories/aws-onboarding.repository.js +134 -0
- package/src/repositories/budget-alert.repository.js +37 -0
- package/src/repositories/budget.repository.js +33 -0
- package/src/repositories/cost-analysis.repository.js +50 -0
- package/src/routes/index.js +31 -0
- package/src/schema/cost-analysis.schema.js +7 -0
- package/src/schema/cost-budget.schema.js +9 -0
- package/src/services/aws-context.service.js +1 -0
- package/src/services/budget-alert.service.js +47 -0
- package/src/services/budget.service.js +32 -0
- package/src/services/cost-analysis-data.service.js +39 -0
- package/src/services/cost-analysis.service.js +190 -0
- package/src/services/cur-provider.service.js +95 -0
- package/src/services/cur.service.js +354 -0
- package/src/services/customer-aws-context.service.js +26 -0
- package/src/services/saas-athena-context.service.js +45 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
|
|
3
|
+
export async function installCurDiscoverySchema(db, qSchema) {
|
|
4
|
+
await db.execute(sql.raw(`CREATE TABLE IF NOT EXISTS ${qSchema}.cost_cur_discovery_jobs (
|
|
5
|
+
id bigserial PRIMARY KEY,
|
|
6
|
+
tenant_id text NOT NULL,
|
|
7
|
+
connection_id text NOT NULL,
|
|
8
|
+
cur_bucket text NOT NULL,
|
|
9
|
+
cur_prefix text NOT NULL,
|
|
10
|
+
cur_region text NOT NULL,
|
|
11
|
+
tenant_partition text NOT NULL,
|
|
12
|
+
status text NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'RUNNING', 'WAITING_FOR_DATA', 'WAITING_FOR_TABLE', 'READY', 'FAILED')),
|
|
13
|
+
attempt_count integer NOT NULL DEFAULT 0,
|
|
14
|
+
next_run_at timestamptz,
|
|
15
|
+
last_started_at timestamptz,
|
|
16
|
+
last_finished_at timestamptz,
|
|
17
|
+
glue_database text,
|
|
18
|
+
glue_table text,
|
|
19
|
+
table_location text,
|
|
20
|
+
cur_s3_uri text,
|
|
21
|
+
sample_object_key text,
|
|
22
|
+
last_data_at timestamptz,
|
|
23
|
+
last_error text,
|
|
24
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
25
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
26
|
+
UNIQUE (tenant_id, connection_id)
|
|
27
|
+
)`));
|
|
28
|
+
// Early CUR builds used uuid here. The discovery API treats tenant IDs as
|
|
29
|
+
// opaque strings, so normalize upgraded databases to the current schema.
|
|
30
|
+
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_cur_discovery_jobs
|
|
31
|
+
ALTER COLUMN tenant_id TYPE text USING tenant_id::text`));
|
|
32
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS cost_cur_discovery_jobs_due_idx ON ${qSchema}.cost_cur_discovery_jobs (status, next_run_at)`));
|
|
33
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS cost_cur_discovery_jobs_tenant_idx ON ${qSchema}.cost_cur_discovery_jobs (tenant_id, connection_id)`));
|
|
34
|
+
await db.execute(sql.raw(`CREATE TABLE IF NOT EXISTS ${qSchema}.cost_cur_discovery_job_logs (
|
|
35
|
+
id bigserial PRIMARY KEY,
|
|
36
|
+
job_id bigint NOT NULL REFERENCES ${qSchema}.cost_cur_discovery_jobs(id) ON DELETE CASCADE,
|
|
37
|
+
tenant_id text NOT NULL,
|
|
38
|
+
level text NOT NULL CHECK (level IN ('INFO', 'WARN', 'ERROR')),
|
|
39
|
+
event text NOT NULL,
|
|
40
|
+
message text NOT NULL,
|
|
41
|
+
details jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
42
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
43
|
+
)`));
|
|
44
|
+
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_cur_discovery_job_logs
|
|
45
|
+
ALTER COLUMN tenant_id TYPE text USING tenant_id::text`));
|
|
46
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS cost_cur_discovery_job_logs_job_idx ON ${qSchema}.cost_cur_discovery_job_logs (job_id, created_at DESC)`));
|
|
47
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS cost_cur_discovery_job_logs_tenant_idx ON ${qSchema}.cost_cur_discovery_job_logs (tenant_id, created_at DESC)`));
|
|
48
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { round } from "./cost-utils.js";
|
|
3
|
+
|
|
4
|
+
const text = (value, max = 1200) => String(value || "").trim().slice(0, max);
|
|
5
|
+
const severity = (value) => ["low", "medium", "high"].includes(value) ? value : "medium";
|
|
6
|
+
|
|
7
|
+
function median(values) {
|
|
8
|
+
const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
|
|
9
|
+
if (!sorted.length) return null;
|
|
10
|
+
const middle = Math.floor(sorted.length / 2);
|
|
11
|
+
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function deltaRows(current = [], previous = [], limit = 8) {
|
|
15
|
+
const before = new Map(previous.map((item) => [item.label, Number(item.amount || 0)]));
|
|
16
|
+
return current.slice(0, limit).map((item) => {
|
|
17
|
+
const currentAmount = Number(item.amount || 0);
|
|
18
|
+
const previousAmount = before.get(item.label) || 0;
|
|
19
|
+
return {
|
|
20
|
+
label: text(item.label, 160),
|
|
21
|
+
current: round(currentAmount),
|
|
22
|
+
previous: round(previousAmount),
|
|
23
|
+
change: round(currentAmount - previousAmount),
|
|
24
|
+
changePercentage: previousAmount > 0 ? round((currentAmount - previousAmount) / previousAmount * 100) : null,
|
|
25
|
+
sharePercentage: Number.isFinite(Number(item.percentage)) ? round(Number(item.percentage)) : null,
|
|
26
|
+
};
|
|
27
|
+
}).sort((a, b) => Math.abs(b.change) - Math.abs(a.change));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildCostFacts({ current, previous }) {
|
|
31
|
+
const currentTotal = round(current.totalCost || 0);
|
|
32
|
+
const previousTotal = round(previous.totalCost || 0);
|
|
33
|
+
const change = round(currentTotal - previousTotal);
|
|
34
|
+
const changePercentage = previousTotal > 0 ? round(change / previousTotal * 100) : null;
|
|
35
|
+
const historicalTotals = (current.trends || [])
|
|
36
|
+
.filter((item) => String(item.month || "") < String(current.period?.Start || ""))
|
|
37
|
+
.map((item) => Number(item.amount || 0))
|
|
38
|
+
.filter((value) => value > 0);
|
|
39
|
+
const baseline = median(historicalTotals);
|
|
40
|
+
const currentVsBaseline = baseline && baseline > 0 ? round((currentTotal - baseline) / baseline * 100) : null;
|
|
41
|
+
const serviceChanges = deltaRows(current.topServices, previous.topServices);
|
|
42
|
+
const accountChanges = deltaRows(current.topAccounts, previous.topAccounts, 6);
|
|
43
|
+
const topServiceShare = Number(current.topServices?.[0]?.percentage || 0);
|
|
44
|
+
const calculatedSignals = [];
|
|
45
|
+
|
|
46
|
+
if (changePercentage != null && Math.abs(changePercentage) >= 10) {
|
|
47
|
+
calculatedSignals.push({
|
|
48
|
+
severity: Math.abs(changePercentage) >= 25 ? "high" : "medium",
|
|
49
|
+
kind: "period_change",
|
|
50
|
+
message: `Spend ${changePercentage > 0 ? "increased" : "decreased"} ${Math.abs(changePercentage)}% compared with the preceding period.`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (currentVsBaseline != null && currentVsBaseline >= 25 && historicalTotals.length >= 3) {
|
|
54
|
+
calculatedSignals.push({ severity: "high", kind: "baseline_anomaly", message: `Spend is ${currentVsBaseline}% above the historical monthly median.` });
|
|
55
|
+
}
|
|
56
|
+
if (topServiceShare >= 50) {
|
|
57
|
+
calculatedSignals.push({ severity: "medium", kind: "service_concentration", message: `${text(current.topServices[0]?.label, 160)} represents ${round(topServiceShare)}% of spend.` });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
period: current.period,
|
|
62
|
+
comparisonPeriod: previous.period,
|
|
63
|
+
currency: current.currency || "USD",
|
|
64
|
+
dataSource: current.dataSource,
|
|
65
|
+
currentTotal,
|
|
66
|
+
previousTotal,
|
|
67
|
+
change,
|
|
68
|
+
changePercentage,
|
|
69
|
+
activeAccounts: Number(current.activeAccounts || 0),
|
|
70
|
+
activeResources: current.activeResources == null ? null : Number(current.activeResources),
|
|
71
|
+
historyMonths: historicalTotals.length,
|
|
72
|
+
historicalMonthlyMedian: baseline == null ? null : round(baseline),
|
|
73
|
+
currentVsBaselinePercentage: currentVsBaseline,
|
|
74
|
+
serviceChanges,
|
|
75
|
+
accountChanges,
|
|
76
|
+
topRegions: (current.topRegions || []).slice(0, 6).map((item) => ({ label: text(item.label, 160), amount: round(item.amount || 0), sharePercentage: round(item.percentage || 0) })),
|
|
77
|
+
calculatedSignals,
|
|
78
|
+
limitations: historicalTotals.length < 3 ? ["Fewer than three non-zero monthly data points are available, so anomaly confidence is limited."] : [],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function redactFactsForModel(facts) {
|
|
83
|
+
return {
|
|
84
|
+
...facts,
|
|
85
|
+
accountChanges: facts.accountChanges.map((item, index) => ({ ...item, label: `Account ${index + 1}` })),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function analysisFingerprint(facts, modelId) {
|
|
90
|
+
return createHash("sha256").update(JSON.stringify({ facts, modelId })).digest("hex");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function parseAnalysisResponse(value) {
|
|
94
|
+
const raw = String(value || "").trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
|
95
|
+
const start = raw.indexOf("{");
|
|
96
|
+
const end = raw.lastIndexOf("}");
|
|
97
|
+
if (start < 0 || end <= start) throw new Error("The AI response did not contain a JSON object");
|
|
98
|
+
const parsed = JSON.parse(raw.slice(start, end + 1));
|
|
99
|
+
const summary = text(parsed.summary, 3000);
|
|
100
|
+
const findings = Array.isArray(parsed.findings) ? parsed.findings.slice(0, 6).map((item) => ({
|
|
101
|
+
severity: severity(item?.severity),
|
|
102
|
+
title: text(item?.title, 160),
|
|
103
|
+
explanation: text(item?.explanation, 1000),
|
|
104
|
+
evidence: text(item?.evidence, 500),
|
|
105
|
+
estimatedImpact: Number.isFinite(Number(item?.estimatedImpact)) ? round(Number(item.estimatedImpact)) : null,
|
|
106
|
+
})).filter((item) => item.title && item.explanation) : [];
|
|
107
|
+
const recommendations = Array.isArray(parsed.recommendations) ? parsed.recommendations.slice(0, 6).map((item) => ({
|
|
108
|
+
priority: Math.min(Math.max(Number(item?.priority) || 2, 1), 3),
|
|
109
|
+
title: text(item?.title, 160),
|
|
110
|
+
action: text(item?.action, 800),
|
|
111
|
+
rationale: text(item?.rationale, 600),
|
|
112
|
+
})).filter((item) => item.title && item.action) : [];
|
|
113
|
+
const limitations = Array.isArray(parsed.limitations) ? parsed.limitations.slice(0, 5).map((item) => text(item, 400)).filter(Boolean) : [];
|
|
114
|
+
if (!summary || !findings.length || !recommendations.length) throw new Error("The AI response was missing required analysis fields");
|
|
115
|
+
return { summary, findings, recommendations, limitations };
|
|
116
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const GROSS_COST_RECORD_TYPES = [
|
|
2
|
+
"Usage", "Fee", "Tax", "Support", "Upfront", "Recurring", "RIFee",
|
|
3
|
+
"DiscountedUsage", "SavingsPlanCoveredUsage", "SavingsPlanRecurringFee",
|
|
4
|
+
"SavingsPlanUpfrontFee",
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
export const truthy = (value) => ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase());
|
|
8
|
+
export const rows = (result) => Array.isArray(result) ? result : result?.rows || [];
|
|
9
|
+
export const iso = (date) => date.toISOString().slice(0, 10);
|
|
10
|
+
export const amount = (metric) => Number(metric?.Amount || 0);
|
|
11
|
+
export const round = (value) => Math.round((Number(value) + Number.EPSILON) * 100) / 100;
|
|
12
|
+
|
|
13
|
+
export function safeSchema(value) {
|
|
14
|
+
const schema = String(value || "meyiconnect");
|
|
15
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(schema)) throw new Error("Invalid DB schema");
|
|
16
|
+
return schema;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function dateRange(query = {}) {
|
|
20
|
+
const end = query.end ? new Date(`${query.end}T00:00:00Z`) : new Date();
|
|
21
|
+
const start = query.start ? new Date(`${query.start}T00:00:00Z`) : new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), 1));
|
|
22
|
+
const exclusiveEnd = new Date(end);
|
|
23
|
+
exclusiveEnd.setUTCDate(exclusiveEnd.getUTCDate() + (query.end ? 1 : 0));
|
|
24
|
+
if (!query.end) exclusiveEnd.setUTCDate(exclusiveEnd.getUTCDate() + 1);
|
|
25
|
+
if (!Number.isFinite(start.getTime()) || !Number.isFinite(exclusiveEnd.getTime()) || start >= exclusiveEnd) {
|
|
26
|
+
const error = new Error("Invalid cost date range");
|
|
27
|
+
error.statusCode = 400;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
return { Start: iso(start), End: iso(exclusiveEnd) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function accountFilter(accountIds) {
|
|
34
|
+
return accountIds.length ? { Dimensions: { Key: "LINKED_ACCOUNT", Values: accountIds } } : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function combineFilters(...values) {
|
|
38
|
+
const filters = values.flat().filter(Boolean);
|
|
39
|
+
return filters.length === 0 ? undefined : filters.length === 1 ? filters[0] : { And: filters };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function listParam(value) {
|
|
43
|
+
const values = Array.isArray(value) ? value : String(value || "").split(",");
|
|
44
|
+
return [...new Set(values.map((item) => String(item).trim()).filter(Boolean))].slice(0, 100);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function grossCostFilter(accountIds, selections = {}) {
|
|
48
|
+
const filters = [];
|
|
49
|
+
const accounts = accountFilter(accountIds);
|
|
50
|
+
if (accounts) filters.push(accounts);
|
|
51
|
+
filters.push({ Dimensions: { Key: "RECORD_TYPE", Values: GROSS_COST_RECORD_TYPES } });
|
|
52
|
+
if (selections.services?.length) filters.push({ Dimensions: { Key: "SERVICE", Values: selections.services } });
|
|
53
|
+
if (selections.regions?.length) filters.push({ Dimensions: { Key: "REGION", Values: selections.regions } });
|
|
54
|
+
if (selections.accountIds?.length) filters.push({ Dimensions: { Key: "LINKED_ACCOUNT", Values: selections.accountIds } });
|
|
55
|
+
if (selections.tagKey && selections.tagValues?.length) filters.push({ Tags: { Key: selections.tagKey, Values: selections.tagValues } });
|
|
56
|
+
return combineFilters(filters);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function normalizeServiceName(value) {
|
|
60
|
+
const name = String(value || "Other");
|
|
61
|
+
return name === "Amazon Elastic Compute Cloud - Compute" || name === "EC2 - Other"
|
|
62
|
+
? "Amazon Elastic Compute Cloud"
|
|
63
|
+
: name;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseGroups(results = [], accountNames = new Map(), groupType) {
|
|
67
|
+
const totals = new Map();
|
|
68
|
+
for (const period of results) {
|
|
69
|
+
for (const group of period.Groups || []) {
|
|
70
|
+
const rawKey = group.Keys?.join(" / ") || "Other";
|
|
71
|
+
const key = groupType === "service" ? normalizeServiceName(rawKey) : rawKey;
|
|
72
|
+
totals.set(key, (totals.get(key) || 0) + amount(group.Metrics?.UnblendedCost));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const sum = [...totals.values()].reduce((total, value) => total + value, 0);
|
|
76
|
+
return [...totals.entries()]
|
|
77
|
+
.filter(([, value]) => Math.abs(value) > 0.000001)
|
|
78
|
+
.map(([key, value]) => ({ key, label: accountNames.get(key) || key, accountId: groupType === "account" ? key : undefined, amount: round(value), percentage: sum ? round(value / sum * 100) : 0 }))
|
|
79
|
+
.sort((a, b) => b.amount - a.amount);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseRelationships(results = [], primaryType, relatedType, accountNames = new Map()) {
|
|
83
|
+
const groupedItems = new Map();
|
|
84
|
+
for (const period of results) {
|
|
85
|
+
for (const group of period.Groups || []) {
|
|
86
|
+
let primary = group.Keys?.[0] || "Other";
|
|
87
|
+
if (primaryType === "service") primary = normalizeServiceName(primary);
|
|
88
|
+
const related = group.Keys?.[1] || "Unallocated";
|
|
89
|
+
if (!groupedItems.has(primary)) groupedItems.set(primary, new Map());
|
|
90
|
+
const values = groupedItems.get(primary);
|
|
91
|
+
values.set(related, (values.get(related) || 0) + amount(group.Metrics?.UnblendedCost));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return new Map([...groupedItems.entries()].map(([key, values]) => [key, [...values.entries()]
|
|
95
|
+
.map(([value, cost]) => ({ key: value, label: relatedType === "account" ? accountNames.get(value) || value : value, amount: round(cost) }))
|
|
96
|
+
.filter((item) => Math.abs(item.amount) > 0.000001)
|
|
97
|
+
.sort((a, b) => b.amount - a.amount)]));
|
|
98
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { BedrockRuntimeClient, ConverseCommand } from "@aws-sdk/client-bedrock-runtime";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One calling convention over the LLM providers the Cost plugin supports.
|
|
5
|
+
*
|
|
6
|
+
* Bedrock authenticates with SigV4 and is subject to account-level model
|
|
7
|
+
* access; the direct Anthropic and OpenAI providers authenticate with an API
|
|
8
|
+
* key and are not. That difference is the whole reason this abstraction exists
|
|
9
|
+
* - a deployment blocked on Bedrock model access can still run analysis
|
|
10
|
+
* against a key.
|
|
11
|
+
*
|
|
12
|
+
* Every provider takes the same call shape and returns { text }. Nothing above
|
|
13
|
+
* this file knows which one answered.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const PROVIDER_BEDROCK = "bedrock";
|
|
17
|
+
export const PROVIDER_ANTHROPIC = "anthropic";
|
|
18
|
+
export const PROVIDER_OPENAI = "openai";
|
|
19
|
+
|
|
20
|
+
const ANTHROPIC_VERSION = "2023-06-01";
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Provider names arrive from a database row that a human filled in through a
|
|
25
|
+
* dropdown ("AWS Bedrock", "Anthropic", "OpenAI"), so normalise loosely rather
|
|
26
|
+
* than demanding an exact token.
|
|
27
|
+
*/
|
|
28
|
+
export function normalizeProviderName(value) {
|
|
29
|
+
const name = String(value || "").trim().toLowerCase();
|
|
30
|
+
if (!name) return PROVIDER_BEDROCK;
|
|
31
|
+
if (name.includes("bedrock")) return PROVIDER_BEDROCK;
|
|
32
|
+
if (name.includes("anthropic") || name.includes("claude")) return PROVIDER_ANTHROPIC;
|
|
33
|
+
if (name.includes("openai") || name.includes("gpt")) return PROVIDER_OPENAI;
|
|
34
|
+
return name;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function providerError(message, { name = "CostAiProviderError", statusCode = 502, cause } = {}) {
|
|
38
|
+
const error = new Error(message);
|
|
39
|
+
error.name = name;
|
|
40
|
+
error.statusCode = statusCode;
|
|
41
|
+
if (cause) error.cause = cause;
|
|
42
|
+
return error;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read an HTTP error body without letting a provider's error text leak a key
|
|
47
|
+
* back to the caller. Bodies are truncated: some providers echo request context.
|
|
48
|
+
*/
|
|
49
|
+
async function readErrorBody(response) {
|
|
50
|
+
try {
|
|
51
|
+
const text = await response.text();
|
|
52
|
+
return String(text || "").slice(0, 500);
|
|
53
|
+
} catch {
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function postJson(url, { headers, body, timeoutMs }) {
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
61
|
+
try {
|
|
62
|
+
return await fetch(url, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "content-type": "application/json", ...headers },
|
|
65
|
+
body: JSON.stringify(body),
|
|
66
|
+
signal: controller.signal,
|
|
67
|
+
});
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error.name === "AbortError") {
|
|
70
|
+
throw providerError(`The model did not respond within ${Math.round(timeoutMs / 1000)}s`, { name: "CostAiTimeoutError", statusCode: 504 });
|
|
71
|
+
}
|
|
72
|
+
throw providerError(`Could not reach the model endpoint: ${error.message}`, { cause: error });
|
|
73
|
+
} finally {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Shared by both Bedrock auth modes: Converse wire shape in, plain text out.
|
|
80
|
+
*
|
|
81
|
+
* Undefined sampling parameters are omitted rather than sent as null. Claude
|
|
82
|
+
* Haiku 4.5 and Sonnet 4.5 reject a request that carries both temperature and
|
|
83
|
+
* topP - "cannot both be specified for this model" - so the caller sends one.
|
|
84
|
+
*/
|
|
85
|
+
function converseRequestBody({ system, messages, maxTokens, temperature, topP }) {
|
|
86
|
+
const inferenceConfig = { maxTokens };
|
|
87
|
+
if (temperature !== undefined && temperature !== null) inferenceConfig.temperature = temperature;
|
|
88
|
+
if (topP !== undefined && topP !== null) inferenceConfig.topP = topP;
|
|
89
|
+
return {
|
|
90
|
+
system: [{ text: system }],
|
|
91
|
+
messages: messages.map((message) => ({ role: message.role, content: [{ text: message.text }] })),
|
|
92
|
+
inferenceConfig,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function converseResponseText(payload) {
|
|
97
|
+
return (payload?.output?.message?.content || []).map((item) => item.text || "").join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Bedrock with an API key (the ABSK... long-term or short-term keys issued in
|
|
102
|
+
* the Bedrock console). These are bearer tokens, not SigV4 credentials, so the
|
|
103
|
+
* AWS SDK cannot use them without the process-wide AWS_BEARER_TOKEN_BEDROCK
|
|
104
|
+
* variable - unusable here, because the token is per tenant and this process
|
|
105
|
+
* serves many. Calling the Converse REST endpoint directly keeps it per request.
|
|
106
|
+
*
|
|
107
|
+
* Authentication only. Model entitlement is still granted per AWS account, so a
|
|
108
|
+
* key cannot reach a model the account has not been approved for.
|
|
109
|
+
*/
|
|
110
|
+
function createBedrockBearerProvider({ modelId, region, apiKey, timeoutMs }) {
|
|
111
|
+
if (!region) throw providerError("Bedrock with an API key requires a region", { name: "CostAiConfigError", statusCode: 400 });
|
|
112
|
+
const endpoint = `https://bedrock-runtime.${region}.amazonaws.com/model/${encodeURIComponent(modelId)}/converse`;
|
|
113
|
+
return {
|
|
114
|
+
provider: PROVIDER_BEDROCK,
|
|
115
|
+
modelId,
|
|
116
|
+
region,
|
|
117
|
+
authMode: "api-key",
|
|
118
|
+
async converse(request) {
|
|
119
|
+
const response = await postJson(endpoint, {
|
|
120
|
+
headers: { authorization: `Bearer ${apiKey}` },
|
|
121
|
+
timeoutMs,
|
|
122
|
+
body: converseRequestBody(request),
|
|
123
|
+
});
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
// 404 here is Bedrock's shape for "account not entitled to this model",
|
|
126
|
+
// not a missing endpoint. Pass the message through so the UI can show it.
|
|
127
|
+
throw providerError(`Bedrock returned ${response.status}: ${await readErrorBody(response)}`, {
|
|
128
|
+
statusCode: response.status === 429 ? 429 : 502,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return { text: converseResponseText(await response.json()) };
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Bedrock with SigV4. The original behaviour: the ambient credential chain (the
|
|
138
|
+
* ECS task role) unless explicit access keys are supplied, which is how a
|
|
139
|
+
* customer points the plugin at their own account.
|
|
140
|
+
*/
|
|
141
|
+
function createBedrockProvider({ modelId, region, apiKey, accessKeyId, secretAccessKey, sessionToken, client, timeoutMs }) {
|
|
142
|
+
// An injected client is a test seam and must win, so check it before the key.
|
|
143
|
+
if (!client && apiKey) return createBedrockBearerProvider({ modelId, region, apiKey, timeoutMs });
|
|
144
|
+
const credentials = accessKeyId && secretAccessKey
|
|
145
|
+
? { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }
|
|
146
|
+
: undefined;
|
|
147
|
+
const runtime = client || new BedrockRuntimeClient({ region, ...(credentials ? { credentials } : {}) });
|
|
148
|
+
return {
|
|
149
|
+
provider: PROVIDER_BEDROCK,
|
|
150
|
+
modelId,
|
|
151
|
+
region,
|
|
152
|
+
authMode: credentials ? "access-keys" : "ambient",
|
|
153
|
+
async converse(request) {
|
|
154
|
+
const response = await runtime.send(new ConverseCommand({ modelId, ...converseRequestBody(request) }));
|
|
155
|
+
return { text: converseResponseText(response) };
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function createAnthropicProvider({ modelId, apiKey, baseUrl, timeoutMs }) {
|
|
161
|
+
if (!apiKey) throw providerError("The Anthropic provider requires an API key", { name: "CostAiConfigError", statusCode: 400 });
|
|
162
|
+
const endpoint = `${String(baseUrl || "https://api.anthropic.com").replace(/\/+$/, "")}/v1/messages`;
|
|
163
|
+
return {
|
|
164
|
+
provider: PROVIDER_ANTHROPIC,
|
|
165
|
+
modelId,
|
|
166
|
+
region: null,
|
|
167
|
+
async converse({ system, messages, maxTokens, temperature, topP }) {
|
|
168
|
+
const response = await postJson(endpoint, {
|
|
169
|
+
headers: { "x-api-key": apiKey, "anthropic-version": ANTHROPIC_VERSION },
|
|
170
|
+
timeoutMs,
|
|
171
|
+
body: {
|
|
172
|
+
model: modelId,
|
|
173
|
+
max_tokens: maxTokens,
|
|
174
|
+
...(temperature === undefined || temperature === null ? {} : { temperature }),
|
|
175
|
+
...(topP === undefined || topP === null ? {} : { top_p: topP }),
|
|
176
|
+
system,
|
|
177
|
+
messages: messages.map((message) => ({ role: message.role, content: message.text })),
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
if (!response.ok) {
|
|
181
|
+
throw providerError(`Anthropic API returned ${response.status}: ${await readErrorBody(response)}`, { statusCode: response.status === 429 ? 429 : 502 });
|
|
182
|
+
}
|
|
183
|
+
const payload = await response.json();
|
|
184
|
+
return { text: (payload.content || []).map((item) => item.text || "").join("\n") };
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function createOpenAiProvider({ modelId, apiKey, baseUrl, timeoutMs }) {
|
|
190
|
+
if (!apiKey) throw providerError("The OpenAI provider requires an API key", { name: "CostAiConfigError", statusCode: 400 });
|
|
191
|
+
const endpoint = `${String(baseUrl || "https://api.openai.com").replace(/\/+$/, "")}/v1/chat/completions`;
|
|
192
|
+
return {
|
|
193
|
+
provider: PROVIDER_OPENAI,
|
|
194
|
+
modelId,
|
|
195
|
+
region: null,
|
|
196
|
+
async converse({ system, messages, maxTokens, temperature, topP }) {
|
|
197
|
+
const response = await postJson(endpoint, {
|
|
198
|
+
headers: { authorization: `Bearer ${apiKey}` },
|
|
199
|
+
timeoutMs,
|
|
200
|
+
body: {
|
|
201
|
+
model: modelId,
|
|
202
|
+
max_completion_tokens: maxTokens,
|
|
203
|
+
...(temperature === undefined || temperature === null ? {} : { temperature }),
|
|
204
|
+
...(topP === undefined || topP === null ? {} : { top_p: topP }),
|
|
205
|
+
messages: [
|
|
206
|
+
{ role: "system", content: system },
|
|
207
|
+
...messages.map((message) => ({ role: message.role, content: message.text })),
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
if (!response.ok) {
|
|
212
|
+
throw providerError(`OpenAI API returned ${response.status}: ${await readErrorBody(response)}`, { statusCode: response.status === 429 ? 429 : 502 });
|
|
213
|
+
}
|
|
214
|
+
const payload = await response.json();
|
|
215
|
+
return { text: (payload.choices || []).map((choice) => choice.message?.content || "").join("\n") };
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* @param {object} config
|
|
222
|
+
* @param {string} config.provider bedrock | anthropic | openai (loose match)
|
|
223
|
+
* @param {string} config.modelId
|
|
224
|
+
* @param {string} [config.apiKey] required for anthropic and openai
|
|
225
|
+
* @param {string} [config.region] bedrock only
|
|
226
|
+
* @param {string} [config.accessKeyId] bedrock only; omit for ambient creds
|
|
227
|
+
* @param {string} [config.secretAccessKey] bedrock only
|
|
228
|
+
* @param {string} [config.baseUrl] overrides the provider endpoint
|
|
229
|
+
* @param {object} [config.client] inject a Bedrock client, for tests
|
|
230
|
+
*/
|
|
231
|
+
export function createLlmProvider(config = {}) {
|
|
232
|
+
const provider = normalizeProviderName(config.provider);
|
|
233
|
+
const timeoutMs = Math.max(Number(config.timeoutMs || DEFAULT_TIMEOUT_MS), 1_000);
|
|
234
|
+
if (!config.modelId) throw providerError("A model id is required", { name: "CostAiConfigError", statusCode: 400 });
|
|
235
|
+
if (provider === PROVIDER_BEDROCK) return createBedrockProvider({ ...config, timeoutMs });
|
|
236
|
+
if (provider === PROVIDER_ANTHROPIC) return createAnthropicProvider({ ...config, timeoutMs });
|
|
237
|
+
if (provider === PROVIDER_OPENAI) return createOpenAiProvider({ ...config, timeoutMs });
|
|
238
|
+
throw providerError(`Unsupported AI provider "${config.provider}"`, { name: "CostAiConfigError", statusCode: 400 });
|
|
239
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export class CostBudget {
|
|
2
|
+
constructor({ id, name, amount, currency = "USD", period = "monthly", spent = 0, alertThreshold = 80, provider = "aws", createdAt, updatedAt }) {
|
|
3
|
+
this.id = id;
|
|
4
|
+
this.name = name;
|
|
5
|
+
this.amount = Number(amount);
|
|
6
|
+
this.currency = currency;
|
|
7
|
+
this.period = period;
|
|
8
|
+
this.spent = Number(spent || 0);
|
|
9
|
+
this.alert_threshold = Number(alertThreshold);
|
|
10
|
+
this.provider = provider;
|
|
11
|
+
this.createdAt = createdAt;
|
|
12
|
+
if (updatedAt !== undefined) this.updatedAt = updatedAt;
|
|
13
|
+
Object.freeze(this);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class BudgetAlertDismissal {
|
|
18
|
+
constructor({ budgetId, period, status, dismissedAt, dismissed }) {
|
|
19
|
+
this.budgetId = budgetId;
|
|
20
|
+
this.period = period;
|
|
21
|
+
this.status = status;
|
|
22
|
+
if (dismissedAt !== undefined) this.dismissedAt = dismissedAt;
|
|
23
|
+
if (dismissed !== undefined) this.dismissed = Boolean(dismissed);
|
|
24
|
+
Object.freeze(this);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export class CurDataStatus {
|
|
2
|
+
constructor({ configured, required = true, ready = false, state, action = null, credentialMode = "saas-runtime", ingestionMode = "central", sourceConfigured = false, lastDataAt = null, recordCount = 0, message = null, discovery = null }) {
|
|
3
|
+
this.configured = Boolean(configured);
|
|
4
|
+
this.required = Boolean(required);
|
|
5
|
+
this.ready = Boolean(ready);
|
|
6
|
+
this.state = state || (this.ready ? "ready" : this.configured ? "pending" : "not_configured");
|
|
7
|
+
this.action = action;
|
|
8
|
+
this.credentialMode = credentialMode;
|
|
9
|
+
this.ingestionMode = ingestionMode;
|
|
10
|
+
this.sourceConfigured = Boolean(sourceConfigured);
|
|
11
|
+
this.lastDataAt = lastDataAt;
|
|
12
|
+
this.recordCount = Number(recordCount || 0);
|
|
13
|
+
this.message = message;
|
|
14
|
+
this.discovery = discovery ? Object.freeze({ ...discovery }) : null;
|
|
15
|
+
Object.freeze(this);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export class CurIngestionDescriptor {
|
|
2
|
+
constructor({ tenant, metadata = {}, env = process.env }) {
|
|
3
|
+
this.mode = String(metadata.curIngestionMode || env.COST_CUR_INGESTION_MODE || "central").trim() || "central";
|
|
4
|
+
this.sourceConfigured = Boolean(metadata.curSourceBucket);
|
|
5
|
+
this.sourceRegion = metadata.curSourceRegion || null;
|
|
6
|
+
this.tenantPartition = String(env.COST_CUR_TENANT_PARTITION || metadata.curTenantPartition || tenant).trim();
|
|
7
|
+
Object.freeze(this);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const normalizeAccount = (account = {}) => Object.freeze({
|
|
2
|
+
id: String(account.id || account.aws_account_id || "").trim(),
|
|
3
|
+
name: String(account.name || account.id || account.aws_account_id || "").trim(),
|
|
4
|
+
region: String(account.region || "global").trim() || "global",
|
|
5
|
+
status: String(account.status || "active").trim() || "active",
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export class CustomerAwsContext {
|
|
9
|
+
constructor({ tenant, accounts = [], meta = {} }) {
|
|
10
|
+
this.tenant = String(tenant || "default").trim() || "default";
|
|
11
|
+
this.accounts = Object.freeze(accounts.map(normalizeAccount).filter((account) => account.id));
|
|
12
|
+
this.meta = Object.freeze({ ...meta });
|
|
13
|
+
Object.freeze(this);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export class SaasCurContext {
|
|
2
|
+
constructor({ tenant, config, client, credentialMode, ingestion }) {
|
|
3
|
+
this.tenant = String(tenant || "default").trim() || "default";
|
|
4
|
+
this.config = Object.freeze({ ...config });
|
|
5
|
+
this.client = client;
|
|
6
|
+
this.credentialMode = credentialMode;
|
|
7
|
+
this.ingestion = ingestion;
|
|
8
|
+
Object.freeze(this);
|
|
9
|
+
}
|
|
10
|
+
}
|
package/src/plugin.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { AwsOnboardingRepository } from "./repositories/aws-onboarding.repository.js";
|
|
2
|
+
import { BudgetRepository } from "./repositories/budget.repository.js";
|
|
3
|
+
import { BudgetAlertRepository } from "./repositories/budget-alert.repository.js";
|
|
4
|
+
import { CustomerAwsContextService } from "./services/customer-aws-context.service.js";
|
|
5
|
+
import { SaasAthenaContextService } from "./services/saas-athena-context.service.js";
|
|
6
|
+
import { BudgetService } from "./services/budget.service.js";
|
|
7
|
+
import { BudgetAlertService } from "./services/budget-alert.service.js";
|
|
8
|
+
import { CurProviderService } from "./services/cur-provider.service.js";
|
|
9
|
+
import { CostController } from "./controllers/cost.controller.js";
|
|
10
|
+
import { BudgetController } from "./controllers/budget.controller.js";
|
|
11
|
+
import { CostAnalysisController } from "./controllers/cost-analysis.controller.js";
|
|
12
|
+
import { createCostRouter } from "./routes/index.js";
|
|
13
|
+
import { installCostBudgetSchema } from "./schema/cost-budget.schema.js";
|
|
14
|
+
import { installCostAnalysisSchema } from "./schema/cost-analysis.schema.js";
|
|
15
|
+
import { CostAnalysisRepository } from "./repositories/cost-analysis.repository.js";
|
|
16
|
+
import { CostAnalysisDataService } from "./services/cost-analysis-data.service.js";
|
|
17
|
+
import { CostAnalysisService } from "./services/cost-analysis.service.js";
|
|
18
|
+
import { installCurDiscoverySchema } from "./cur-discovery/schema.js";
|
|
19
|
+
import { CurDiscoveryRepository } from "./cur-discovery/cur-discovery.repository.js";
|
|
20
|
+
import { CurDiscoveryAws } from "./cur-discovery/cur-discovery.aws.js";
|
|
21
|
+
import { CurDiscoveryService } from "./cur-discovery/cur-discovery.service.js";
|
|
22
|
+
import { CurDiscoveryWorker } from "./cur-discovery/cur-discovery.worker.js";
|
|
23
|
+
import { safeSchema } from "./lib/cost-utils.js";
|
|
24
|
+
|
|
25
|
+
export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = console, providerResolver = null } = {}) {
|
|
26
|
+
if (!db) throw new Error("db is required");
|
|
27
|
+
const schema = safeSchema(process.env.DB_SCHEMA || "meyiconnect");
|
|
28
|
+
const qSchema = `"${schema}"`;
|
|
29
|
+
const defaultTenant = String(process.env.DEFAULT_TENANT_ID || "default").trim() || "default";
|
|
30
|
+
const onboardingRepository = new AwsOnboardingRepository({ db, schema });
|
|
31
|
+
const contextService = new CustomerAwsContextService({ repository: onboardingRepository, defaultTenant });
|
|
32
|
+
const budgetRepository = new BudgetRepository({ db, qSchema });
|
|
33
|
+
const budgetAlertRepository = new BudgetAlertRepository({ db, qSchema });
|
|
34
|
+
const costAnalysisRepository = new CostAnalysisRepository({ db, qSchema });
|
|
35
|
+
const budgetService = new BudgetService({ repository: budgetRepository });
|
|
36
|
+
const budgetAlertService = new BudgetAlertService({ repository: budgetAlertRepository });
|
|
37
|
+
const athenaContextService = new SaasAthenaContextService();
|
|
38
|
+
const curProvider = new CurProviderService({ athenaContextService, logger });
|
|
39
|
+
const discoveryRepository = new CurDiscoveryRepository({ db, schema, qSchema });
|
|
40
|
+
const discoveryAws = new CurDiscoveryAws();
|
|
41
|
+
const discoveryIntervalMs = Math.max(Number(process.env.COST_CUR_DISCOVERY_INTERVAL_MS || 3_600_000), 60_000);
|
|
42
|
+
const discoveryService = new CurDiscoveryService({
|
|
43
|
+
repository: discoveryRepository,
|
|
44
|
+
aws: discoveryAws,
|
|
45
|
+
athenaContextService,
|
|
46
|
+
logger,
|
|
47
|
+
intervalMs: discoveryIntervalMs,
|
|
48
|
+
});
|
|
49
|
+
const discoveryWorker = new CurDiscoveryWorker({ repository: discoveryRepository, service: discoveryService, logger });
|
|
50
|
+
const costController = new CostController({ contextService, curProvider, discoveryRepository, logger });
|
|
51
|
+
const budgetController = new BudgetController({ contextService, budgetService, budgetAlertService });
|
|
52
|
+
const costAnalysisDataService = new CostAnalysisDataService({ curProvider });
|
|
53
|
+
// providerResolver lets the host supply per-tenant LLM credentials (the AI
|
|
54
|
+
// Providers screen). Null keeps the environment-only behaviour.
|
|
55
|
+
const costAnalysisService = new CostAnalysisService({ contextService, dataService: costAnalysisDataService, repository: costAnalysisRepository, logger, providerResolver });
|
|
56
|
+
const costAnalysisController = new CostAnalysisController({ service: costAnalysisService });
|
|
57
|
+
const router = createCostRouter({ costController, budgetController, costAnalysisController }, logger);
|
|
58
|
+
let mounted = false;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
async install() {
|
|
62
|
+
await installCostBudgetSchema(db, qSchema);
|
|
63
|
+
await installCostAnalysisSchema(db, qSchema);
|
|
64
|
+
await installCurDiscoverySchema(db, qSchema);
|
|
65
|
+
logger.log?.("[Cost] Database migration verified");
|
|
66
|
+
},
|
|
67
|
+
async start() {
|
|
68
|
+
if (app && !mounted) {
|
|
69
|
+
app.use(`${apiBaseUri}/cost`, router);
|
|
70
|
+
mounted = true;
|
|
71
|
+
}
|
|
72
|
+
discoveryWorker.start();
|
|
73
|
+
logger.log?.(`[Cost] Routes active at ${apiBaseUri}/cost/*`);
|
|
74
|
+
},
|
|
75
|
+
async stop() {
|
|
76
|
+
discoveryWorker.stop();
|
|
77
|
+
},
|
|
78
|
+
router,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default createInsightCost;
|