@meyicloud/meyi-cost-server 1.4.1 → 1.6.0

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.
@@ -0,0 +1,158 @@
1
+ import {
2
+ DescribeTasksCommand,
3
+ ECSClient,
4
+ RunTaskCommand,
5
+ StopTaskCommand,
6
+ } from "@aws-sdk/client-ecs";
7
+ import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
8
+ import { truthy } from "../lib/cost-utils.js";
9
+
10
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
11
+ const list = (value) => String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
12
+
13
+ function required(value, name) {
14
+ const text = String(value || "").trim();
15
+ if (!text) throw Object.assign(new Error(`${name} is required for external cost analysis`), { name: "CostAnalyserConfigError", statusCode: 503 });
16
+ return text;
17
+ }
18
+
19
+ function environment(values) {
20
+ return Object.entries(values).filter(([, value]) => value !== undefined && value !== null && value !== "").map(([name, value]) => ({ name, value: String(value) }));
21
+ }
22
+
23
+ async function bodyText(body) {
24
+ if (!body) return "";
25
+ if (typeof body.transformToString === "function") return body.transformToString();
26
+ const chunks = [];
27
+ for await (const chunk of body) chunks.push(Buffer.from(chunk));
28
+ return Buffer.concat(chunks).toString("utf8");
29
+ }
30
+
31
+ export class CostAnalyserService {
32
+ constructor({ contextService, athenaContextService, logger = console, env = process.env, ecsClient = null, s3Client = null } = {}) {
33
+ this.contextService = contextService;
34
+ this.athenaContextService = athenaContextService;
35
+ this.logger = logger;
36
+ this.enabled = truthy(env.COST_AI_ENABLED);
37
+ this.region = String(env.COST_AI_ANALYSER_REGION || env.AWS_REGION || env.AWS_DEFAULT_REGION || "us-east-1");
38
+ this.cluster = String(env.COST_AI_ANALYSER_CLUSTER || "").trim();
39
+ this.taskDefinition = String(env.COST_AI_ANALYSER_TASK_DEFINITION || "").trim();
40
+ this.containerName = String(env.COST_AI_ANALYSER_CONTAINER_NAME || "cost-ai-analyser").trim();
41
+ this.subnets = list(env.COST_AI_ANALYSER_SUBNETS);
42
+ this.securityGroups = list(env.COST_AI_ANALYSER_SECURITY_GROUPS);
43
+ this.assignPublicIp = truthy(env.COST_AI_ANALYSER_ASSIGN_PUBLIC_IP) ? "ENABLED" : "DISABLED";
44
+ this.reportBucket = String(env.COST_AI_ANALYSER_REPORT_BUCKET || "").trim();
45
+ this.targetRegions = String(env.COST_AI_ANALYSER_TARGET_REGIONS || env.AWS_REGION || "us-east-1").trim();
46
+ this.bedrockRegion = String(env.COST_AI_ANALYSER_BEDROCK_REGION || "").trim();
47
+ this.bedrockModelId = String(env.COST_AI_ANALYSER_BEDROCK_MODEL_ID || "").trim();
48
+ this.topServices = String(env.COST_AI_ANALYSER_TOP_N_SERVICES || "10").trim();
49
+ this.pollMs = Math.max(Number(env.COST_AI_ANALYSER_POLL_INTERVAL_MS || 15_000), 2_000);
50
+ this.timeoutMs = Math.max(Number(env.COST_AI_ANALYSER_TIMEOUT_MS || 45 * 60_000), 60_000);
51
+ this.ecs = ecsClient || new ECSClient({ region: this.region });
52
+ this.s3 = s3Client || new S3Client({ region: this.region });
53
+ }
54
+
55
+ status() {
56
+ const configured = Boolean(this.cluster && this.taskDefinition && this.subnets.length && this.securityGroups.length && this.reportBucket && this.bedrockModelId);
57
+ return {
58
+ enabled: this.enabled && configured,
59
+ provider: "meyi-cost-ai-analyser",
60
+ modelId: configured ? this.bedrockModelId : null,
61
+ region: configured ? this.bedrockRegion : null,
62
+ source: "ecs-task",
63
+ };
64
+ }
65
+
66
+ validate() {
67
+ if (!this.enabled) throw Object.assign(new Error("AI cost analysis is not enabled for this deployment"), { name: "CostAiDisabledError", statusCode: 503 });
68
+ required(this.cluster, "COST_AI_ANALYSER_CLUSTER");
69
+ required(this.taskDefinition, "COST_AI_ANALYSER_TASK_DEFINITION");
70
+ if (!this.subnets.length) required("", "COST_AI_ANALYSER_SUBNETS");
71
+ if (!this.securityGroups.length) required("", "COST_AI_ANALYSER_SECURITY_GROUPS");
72
+ required(this.reportBucket, "COST_AI_ANALYSER_REPORT_BUCKET");
73
+ required(this.bedrockModelId, "COST_AI_ANALYSER_BEDROCK_MODEL_ID");
74
+ }
75
+
76
+ async readJson(key) {
77
+ const response = await this.s3.send(new GetObjectCommand({ Bucket: this.reportBucket, Key: key }));
78
+ return JSON.parse(await bodyText(response.Body));
79
+ }
80
+
81
+ async execute({ tenantId, reportId, range, frequency, onStarted = null }) {
82
+ this.validate();
83
+ const customer = await this.contextService.resolve({ headers: {}, user: { tenant_id: tenantId, tenantId } });
84
+ const athena = this.athenaContextService.resolve(customer).config;
85
+ if (!athena.enabled) throw Object.assign(new Error("CUR discovery has not produced a queryable Athena table for this tenant"), { name: "CurDataNotReadyError", statusCode: 503 });
86
+ const targetRoleArn = required(customer.meta?.payerRoleArn, "customer TARGET_ROLE_ARN");
87
+ const targetAccountId = required(customer.meta?.managementAccountId || customer.accounts?.[0]?.id, "customer management account ID");
88
+ const prefix = `tenants/${tenantId}/reports/${reportId}`;
89
+ const values = {
90
+ REPORT_JOB_ID: reportId,
91
+ REPORT_TENANT_ID: tenantId,
92
+ REPORT_KEY_PREFIX: prefix,
93
+ REPORT_START_DATE: range.Start,
94
+ REPORT_END_DATE: range.End,
95
+ REPORT_FREQUENCY: frequency,
96
+ REPORT_BUCKET: this.reportBucket,
97
+ REPORT_BUCKET_REGION: this.region,
98
+ TARGET_ACCOUNT_ID: targetAccountId,
99
+ TARGET_ROLE_ARN: targetRoleArn,
100
+ TARGET_ROLE_EXTERNAL_ID: customer.meta?.externalId,
101
+ TARGET_REGIONS: this.targetRegions,
102
+ COST_CUR_DATABASE: athena.database,
103
+ COST_CUR_TABLE: athena.table,
104
+ COST_CUR_WORKGROUP: athena.workgroup,
105
+ COST_CUR_OUTPUT_LOCATION: athena.outputLocation,
106
+ COST_CUR_REGION: athena.region,
107
+ COST_CUR_TENANT_COLUMN: athena.tenantColumn,
108
+ COST_CUR_TENANT_PARTITION: athena.tenantPartition || tenantId,
109
+ BEDROCK_REGION: this.bedrockRegion,
110
+ BEDROCK_MODEL_ID: this.bedrockModelId,
111
+ TOP_N_SERVICES: this.topServices,
112
+ };
113
+ const launched = await this.ecs.send(new RunTaskCommand({
114
+ cluster: this.cluster,
115
+ taskDefinition: this.taskDefinition,
116
+ launchType: "FARGATE",
117
+ count: 1,
118
+ enableExecuteCommand: false,
119
+ networkConfiguration: { awsvpcConfiguration: { subnets: this.subnets, securityGroups: this.securityGroups, assignPublicIp: this.assignPublicIp } },
120
+ overrides: { containerOverrides: [{ name: this.containerName, environment: environment(values) }] },
121
+ startedBy: `meyi-cost-${reportId}`.slice(0, 36),
122
+ }));
123
+ if (launched.failures?.length || !launched.tasks?.[0]?.taskArn) {
124
+ const reason = launched.failures?.map((item) => item.reason || item.detail).filter(Boolean).join("; ") || "ECS did not return a task ARN";
125
+ throw Object.assign(new Error(`Unable to start cost analyser: ${reason}`), { name: "CostAnalyserLaunchError", statusCode: 502 });
126
+ }
127
+ const taskArn = launched.tasks[0].taskArn;
128
+ await onStarted?.(taskArn);
129
+ const deadline = Date.now() + this.timeoutMs;
130
+ while (Date.now() < deadline) {
131
+ const response = await this.ecs.send(new DescribeTasksCommand({ cluster: this.cluster, tasks: [taskArn] }));
132
+ const task = response.tasks?.[0];
133
+ if (task?.lastStatus === "STOPPED") {
134
+ const container = task.containers?.find((item) => item.name === this.containerName) || task.containers?.[0];
135
+ let status;
136
+ try { status = await this.readJson(`${prefix}/status.json`); } catch { status = null; }
137
+ if (Number(container?.exitCode ?? 1) !== 0 || status?.status !== "COMPLETED") {
138
+ throw Object.assign(new Error(status?.error || container?.reason || task.stoppedReason || "Cost analyser task failed"), { name: "CostAnalyserTaskError", statusCode: 502, taskArn });
139
+ }
140
+ return {
141
+ taskArn,
142
+ artifactBucket: this.reportBucket,
143
+ markdownKey: status.markdownKey || `${prefix}/report.md`,
144
+ pdfKey: status.pdfKey || `${prefix}/report.pdf`,
145
+ markdownSize: Number(status.markdownSize || 0),
146
+ pdfSize: Number(status.pdfSize || 0),
147
+ modelId: this.bedrockModelId,
148
+ dataSource: "AWS CUR",
149
+ facts: { currentTotal: Number(status.summary?.totalMonthlyCost || 0), currency: "USD" },
150
+ summary: status.summary || {},
151
+ };
152
+ }
153
+ await sleep(this.pollMs);
154
+ }
155
+ await this.ecs.send(new StopTaskCommand({ cluster: this.cluster, task: taskArn, reason: "Meyi cost analysis timeout" }));
156
+ throw Object.assign(new Error("Cost analyser task timed out"), { name: "CostAnalyserTimeoutError", statusCode: 504, taskArn });
157
+ }
158
+ }
@@ -0,0 +1,68 @@
1
+ import { nextScheduleRun, reportRange, validateSchedule } from "../lib/cost-analysis-schedule.js";
2
+
3
+ function scheduleDto(row) {
4
+ if (!row) return { enabled: false, frequency: "weekly", time: "09:00", timezone: "UTC", dayOfWeek: 1, dayOfMonth: 1, nextRunAt: null, lastRunAt: null };
5
+ return { enabled: row.enabled, frequency: row.frequency, time: String(row.time_of_day).slice(0, 5), timezone: row.timezone, dayOfWeek: row.day_of_week, dayOfMonth: row.day_of_month, nextRunAt: row.next_run_at, lastRunAt: row.last_run_at };
6
+ }
7
+
8
+ function reportDto(row) {
9
+ return {
10
+ id: row.id, frequency: row.schedule_frequency, scheduledFor: row.scheduled_for,
11
+ period: { Start: String(row.period_start).slice(0, 10), End: String(row.period_end).slice(0, 10) },
12
+ status: row.status, attempts: Number(row.attempt_count || 0), modelId: row.model_id,
13
+ dataSource: row.data_source, facts: row.facts || null, ...(row.result || {}),
14
+ pdfReady: row.status === "COMPLETED" && Boolean(row.pdf_key || row.pdf_data || Number(row.pdf_size || 0) > 0),
15
+ pdfSize: Number(row.pdf_size || 0), error: row.error, startedAt: row.started_at,
16
+ completedAt: row.completed_at, createdAt: row.created_at,
17
+ };
18
+ }
19
+
20
+ export class CostAnalysisReportService {
21
+ constructor({ contextService, repository, artifactService }) {
22
+ this.contextService = contextService;
23
+ this.repository = repository;
24
+ this.artifactService = artifactService;
25
+ }
26
+
27
+ tenant(req) { return this.contextService.tenantId(req); }
28
+
29
+ async getSchedule(req) { return scheduleDto(await this.repository.getSchedule(this.tenant(req))); }
30
+
31
+ async saveSchedule(req, input) {
32
+ if (String(req.user?.role || "").toLowerCase() !== "admin") throw Object.assign(new Error("Administrator access is required to change the AI report schedule"), { statusCode: 403 });
33
+ const schedule = validateSchedule(input);
34
+ const nextRunAt = schedule.enabled ? nextScheduleRun(schedule) : null;
35
+ return scheduleDto(await this.repository.saveSchedule(this.tenant(req), schedule, nextRunAt));
36
+ }
37
+
38
+ async list(req, limit) { return (await this.repository.list(this.tenant(req), limit)).map(reportDto); }
39
+
40
+ async latest(req) {
41
+ const reports = await this.list(req, 1);
42
+ return reports[0] || null;
43
+ }
44
+
45
+ async get(req, id) {
46
+ const row = await this.repository.find(this.tenant(req), id);
47
+ if (!row) throw Object.assign(new Error("AI cost report was not found"), { statusCode: 404 });
48
+ return reportDto(row);
49
+ }
50
+
51
+ async pdf(req, id) {
52
+ const row = await this.repository.find(this.tenant(req), id, true);
53
+ if (!row) throw Object.assign(new Error("AI cost report was not found"), { statusCode: 404 });
54
+ if (row.status !== "COMPLETED") throw Object.assign(new Error("The PDF is not ready"), { statusCode: 409 });
55
+ const data = row.pdf_key ? await this.artifactService.get(row.artifact_bucket, row.pdf_key) : row.pdf_data ? Buffer.from(row.pdf_data) : null;
56
+ if (!data?.length) throw Object.assign(new Error("The PDF artifact is unavailable"), { statusCode: 409 });
57
+ return { data, filename: `meyi-cost-analysis-${String(row.period_start).slice(0, 10)}-${id.slice(0, 8)}.pdf` };
58
+ }
59
+
60
+ async markdown(req, id) {
61
+ const row = await this.repository.find(this.tenant(req), id, true);
62
+ if (!row) throw Object.assign(new Error("AI cost report was not found"), { statusCode: 404 });
63
+ if (row.status !== "COMPLETED" || !row.markdown_key) throw Object.assign(new Error("The Markdown report is not ready"), { statusCode: 409 });
64
+ const data = await this.artifactService.get(row.artifact_bucket, row.markdown_key);
65
+ if (!data?.length) throw Object.assign(new Error("The Markdown artifact is unavailable"), { statusCode: 409 });
66
+ return { data, filename: `meyi-cost-analysis-${String(row.period_start).slice(0, 10)}-${id.slice(0, 8)}.md` };
67
+ }
68
+ }
@@ -0,0 +1,22 @@
1
+ import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
2
+
3
+ async function bytes(body) {
4
+ if (!body) return Buffer.alloc(0);
5
+ if (typeof body.transformToByteArray === "function") return Buffer.from(await body.transformToByteArray());
6
+ const chunks = [];
7
+ for await (const chunk of body) chunks.push(Buffer.from(chunk));
8
+ return Buffer.concat(chunks);
9
+ }
10
+
11
+ export class CostReportArtifactService {
12
+ constructor({ client = null, region = process.env.COST_AI_ANALYSER_REGION || process.env.AWS_REGION } = {}) {
13
+ this.client = client || new S3Client({ region });
14
+ }
15
+
16
+ async get(bucket, key) {
17
+ if (!bucket || !key) return null;
18
+ const response = await this.client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
19
+ return bytes(response.Body);
20
+ }
21
+ }
22
+
@@ -0,0 +1,67 @@
1
+ import { nextScheduleRun, reportRange } from "../lib/cost-analysis-schedule.js";
2
+ export class CostAnalysisReportWorker {
3
+ constructor({ repository, analyserService, logger = console, env = process.env }) {
4
+ this.repository = repository;
5
+ this.analyserService = analyserService;
6
+ this.logger = logger;
7
+ this.enabled = String(env.COST_AI_ENABLED || "").toLowerCase() === "true";
8
+ this.pollMs = Math.max(Number(env.COST_AI_REPORT_POLL_INTERVAL_MS || 60_000), 10_000);
9
+ this.leaseMs = Math.max(Number(env.COST_AI_REPORT_LEASE_MS || 30 * 60_000), 60_000);
10
+ this.batchSize = Math.min(Math.max(Number(env.COST_AI_REPORT_BATCH_SIZE || 5), 1), 25);
11
+ this.running = false;
12
+ this.timer = null;
13
+ this.initialTimer = null;
14
+ }
15
+
16
+ async run(schedule) {
17
+ const normalized = { ...schedule, time: String(schedule.time_of_day).slice(0, 5), dayOfWeek: schedule.day_of_week, dayOfMonth: schedule.day_of_month };
18
+ const range = reportRange(normalized, schedule.scheduled_for);
19
+ const report = await this.repository.createReport(schedule, range);
20
+ const nextRunAt = nextScheduleRun(normalized, new Date());
21
+ if (!report) return this.repository.setNextRun(schedule.tenant_id, nextRunAt, false, schedule.next_run_at);
22
+ const claimed = await this.repository.markRunning(report.id);
23
+ if (!claimed) return this.repository.setNextRun(schedule.tenant_id, nextRunAt, false, schedule.next_run_at);
24
+ try {
25
+ const result = await this.analyserService.execute({
26
+ tenantId: schedule.tenant_id,
27
+ reportId: report.id,
28
+ range,
29
+ frequency: schedule.frequency,
30
+ onStarted: (taskArn) => this.repository.setTaskArn(report.id, taskArn),
31
+ });
32
+ await this.repository.completeExternal(report.id, result);
33
+ await this.repository.setNextRun(schedule.tenant_id, nextRunAt, true, schedule.next_run_at);
34
+ } catch (error) {
35
+ await this.repository.fail(report.id, error);
36
+ await this.repository.setNextRun(schedule.tenant_id, nextRunAt, true, schedule.next_run_at);
37
+ this.logger.error?.("[Cost AI Reports] Scheduled analysis failed", { tenantId: schedule.tenant_id, reportId: report.id, message: error.message });
38
+ }
39
+ }
40
+
41
+ async tick() {
42
+ if (!this.enabled || this.running) return;
43
+ this.running = true;
44
+ try {
45
+ const schedules = await this.repository.claimDue(this.batchSize, this.leaseMs);
46
+ for (const schedule of schedules) await this.run(schedule);
47
+ } catch (error) {
48
+ this.logger.error?.("[Cost AI Reports] Scheduler tick failed", error);
49
+ } finally { this.running = false; }
50
+ }
51
+
52
+ start() {
53
+ if (!this.enabled || this.timer) return;
54
+ this.initialTimer = setTimeout(() => void this.tick(), 2_000);
55
+ this.initialTimer.unref?.();
56
+ this.timer = setInterval(() => void this.tick(), this.pollMs);
57
+ this.timer.unref?.();
58
+ this.logger.log?.(`[Cost AI Reports] Scheduler started; poll=${Math.round(this.pollMs / 1000)}s`);
59
+ }
60
+
61
+ stop() {
62
+ if (this.initialTimer) clearTimeout(this.initialTimer);
63
+ if (this.timer) clearInterval(this.timer);
64
+ this.initialTimer = null;
65
+ this.timer = null;
66
+ }
67
+ }
@@ -1,116 +0,0 @@
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
- }
@@ -1,239 +0,0 @@
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
- }