@jadenrazo/cloudcost-mcp 1.0.0 → 1.2.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.
@@ -8,10 +8,11 @@ import {
8
8
  getGcpSqlPricing,
9
9
  getGcpStoragePricing,
10
10
  getInstanceMap,
11
+ getPricingMetadata,
11
12
  getRegionMappings,
12
13
  getRegionPriceMultipliers,
13
14
  getStorageMap
14
- } from "./chunk-TRRAOOVF.js";
15
+ } from "./chunk-LWTVE7UY.js";
15
16
 
16
17
  // src/config.ts
17
18
  import { readFileSync, existsSync } from "fs";
@@ -39,9 +40,39 @@ var DEFAULT_CONFIG = {
39
40
  },
40
41
  budget: {
41
42
  warn_percentage: 80
43
+ },
44
+ guardrail: {
45
+ warn_ratio: 0.8
42
46
  }
43
47
  };
44
48
 
49
+ // src/logger.ts
50
+ var LEVEL_ORDER = {
51
+ debug: 0,
52
+ info: 1,
53
+ warn: 2,
54
+ error: 3
55
+ };
56
+ var currentLevel = "info";
57
+ function setLogLevel(level) {
58
+ currentLevel = level;
59
+ }
60
+ function shouldLog(level) {
61
+ return LEVEL_ORDER[level] >= LEVEL_ORDER[currentLevel];
62
+ }
63
+ function log(level, message, data) {
64
+ if (!shouldLog(level)) return;
65
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
66
+ const entry = data ? `[${timestamp}] ${level.toUpperCase()}: ${message} ${JSON.stringify(data)}` : `[${timestamp}] ${level.toUpperCase()}: ${message}`;
67
+ process.stderr.write(entry + "\n");
68
+ }
69
+ var logger = {
70
+ debug: (msg, data) => log("debug", msg, data),
71
+ info: (msg, data) => log("info", msg, data),
72
+ warn: (msg, data) => log("warn", msg, data),
73
+ error: (msg, data) => log("error", msg, data)
74
+ };
75
+
45
76
  // src/config.ts
46
77
  function getConfigDir() {
47
78
  return join(homedir(), ".cloudcost");
@@ -51,7 +82,11 @@ function loadFileConfig() {
51
82
  if (!existsSync(configPath)) return {};
52
83
  try {
53
84
  return JSON.parse(readFileSync(configPath, "utf-8"));
54
- } catch {
85
+ } catch (err) {
86
+ logger.warn("Ignoring malformed config file; using defaults", {
87
+ path: configPath,
88
+ error: err instanceof Error ? err.message : String(err)
89
+ });
55
90
  return {};
56
91
  }
57
92
  }
@@ -101,6 +136,23 @@ function loadEnvConfig() {
101
136
  ...warnPct !== void 0 && !isNaN(warnPct) && { warn_percentage: warnPct }
102
137
  };
103
138
  }
139
+ if (env.CLOUDCOST_GUARDRAIL_MAX_MONTHLY || env.CLOUDCOST_GUARDRAIL_MAX_PER_RESOURCE || env.CLOUDCOST_GUARDRAIL_WARN_RATIO) {
140
+ const parsePositive = (raw) => {
141
+ if (!raw) return void 0;
142
+ const v = parseFloat(raw);
143
+ return Number.isFinite(v) && v > 0 ? v : void 0;
144
+ };
145
+ const maxMonthly = parsePositive(env.CLOUDCOST_GUARDRAIL_MAX_MONTHLY);
146
+ const maxPerResource = parsePositive(env.CLOUDCOST_GUARDRAIL_MAX_PER_RESOURCE);
147
+ const warnRatioRaw = env.CLOUDCOST_GUARDRAIL_WARN_RATIO ? parseFloat(env.CLOUDCOST_GUARDRAIL_WARN_RATIO) : NaN;
148
+ const warnRatio = Number.isFinite(warnRatioRaw) && warnRatioRaw > 0 && warnRatioRaw <= 1 ? warnRatioRaw : void 0;
149
+ config.guardrail = {
150
+ ...DEFAULT_CONFIG.guardrail,
151
+ ...maxMonthly !== void 0 && { max_monthly: maxMonthly },
152
+ ...maxPerResource !== void 0 && { max_per_resource: maxPerResource },
153
+ ...warnRatio !== void 0 && { warn_ratio: warnRatio }
154
+ };
155
+ }
104
156
  return config;
105
157
  }
106
158
  function loadConfig() {
@@ -133,43 +185,94 @@ function loadConfig() {
133
185
  ...DEFAULT_CONFIG.budget,
134
186
  ...fileConfig.budget,
135
187
  ...envConfig.budget
188
+ },
189
+ guardrail: {
190
+ ...DEFAULT_CONFIG.guardrail,
191
+ ...fileConfig.guardrail,
192
+ ...envConfig.guardrail
136
193
  }
137
194
  };
138
195
  }
139
196
 
140
- // src/pricing/cache.ts
141
- import Database from "better-sqlite3";
142
- import { mkdirSync } from "fs";
143
- import { dirname } from "path";
144
-
145
- // src/logger.ts
146
- var LEVEL_ORDER = {
147
- debug: 0,
148
- info: 1,
149
- warn: 2,
150
- error: 3
197
+ // src/currency.ts
198
+ var SUPPORTED_CURRENCIES = [
199
+ "USD",
200
+ "EUR",
201
+ "GBP",
202
+ "JPY",
203
+ "CAD",
204
+ "AUD",
205
+ "INR",
206
+ "BRL"
207
+ ];
208
+ var EXCHANGE_RATES_AS_OF = "2026-03";
209
+ var EXCHANGE_RATES = {
210
+ USD: 1,
211
+ EUR: 0.92,
212
+ GBP: 0.79,
213
+ JPY: 149.5,
214
+ CAD: 1.36,
215
+ AUD: 1.53,
216
+ INR: 83.1,
217
+ BRL: 4.97
151
218
  };
152
- var currentLevel = "info";
153
- function setLogLevel(level) {
154
- currentLevel = level;
219
+ function convertCurrency(amountUsd, targetCurrency) {
220
+ const rate = EXCHANGE_RATES[targetCurrency];
221
+ if (rate === void 0 || rate === 1) {
222
+ return amountUsd;
223
+ }
224
+ return amountUsd * rate;
155
225
  }
156
- function shouldLog(level) {
157
- return LEVEL_ORDER[level] >= LEVEL_ORDER[currentLevel];
226
+ function getExchangeRateInfo(currency) {
227
+ return {
228
+ rate: EXCHANGE_RATES[currency] ?? 1,
229
+ rate_as_of: EXCHANGE_RATES_AS_OF
230
+ };
158
231
  }
159
- function log(level, message, data) {
160
- if (!shouldLog(level)) return;
161
- const timestamp = (/* @__PURE__ */ new Date()).toISOString();
162
- const entry = data ? `[${timestamp}] ${level.toUpperCase()}: ${message} ${JSON.stringify(data)}` : `[${timestamp}] ${level.toUpperCase()}: ${message}`;
163
- process.stderr.write(entry + "\n");
232
+ function convertLineItem(item, currency) {
233
+ return {
234
+ ...item,
235
+ unit_price: convertCurrency(item.unit_price, currency),
236
+ monthly_cost: convertCurrency(item.monthly_cost, currency)
237
+ };
238
+ }
239
+ function convertCostEstimate(estimate, currency) {
240
+ return {
241
+ ...estimate,
242
+ monthly_cost: convertCurrency(estimate.monthly_cost, currency),
243
+ yearly_cost: convertCurrency(estimate.yearly_cost, currency),
244
+ currency,
245
+ breakdown: estimate.breakdown.map((item) => convertLineItem(item, currency))
246
+ };
247
+ }
248
+ function convertBreakdownCurrency(breakdown, currency) {
249
+ const original_usd = {
250
+ total_monthly: breakdown.total_monthly,
251
+ total_yearly: breakdown.total_yearly
252
+ };
253
+ const convertedByService = {};
254
+ for (const [service, amount] of Object.entries(breakdown.by_service)) {
255
+ convertedByService[service] = convertCurrency(amount, currency);
256
+ }
257
+ return {
258
+ ...breakdown,
259
+ total_monthly: convertCurrency(breakdown.total_monthly, currency),
260
+ total_yearly: convertCurrency(breakdown.total_yearly, currency),
261
+ currency,
262
+ by_service: convertedByService,
263
+ by_resource: breakdown.by_resource.map((est) => convertCostEstimate(est, currency)),
264
+ ...breakdown.estimated_egress_monthly !== void 0 && {
265
+ estimated_egress_monthly: convertCurrency(breakdown.estimated_egress_monthly, currency)
266
+ },
267
+ original_usd,
268
+ exchange_rate: getExchangeRateInfo(currency)
269
+ };
164
270
  }
165
- var logger = {
166
- debug: (msg, data) => log("debug", msg, data),
167
- info: (msg, data) => log("info", msg, data),
168
- warn: (msg, data) => log("warn", msg, data),
169
- error: (msg, data) => log("error", msg, data)
170
- };
171
271
 
172
272
  // src/pricing/cache.ts
273
+ import Database from "better-sqlite3";
274
+ import { mkdirSync } from "fs";
275
+ import { dirname } from "path";
173
276
  var SCHEMA = `
174
277
  CREATE TABLE IF NOT EXISTS pricing_cache (
175
278
  key TEXT PRIMARY KEY,
@@ -525,6 +628,15 @@ var PricingCache = class {
525
628
  }
526
629
  };
527
630
 
631
+ // src/pricing/effective-date.ts
632
+ function resolveEffectiveDate(effectiveDate) {
633
+ if (effectiveDate) {
634
+ const t = new Date(effectiveDate);
635
+ if (!Number.isNaN(t.getTime())) return t.toISOString();
636
+ }
637
+ return (/* @__PURE__ */ new Date()).toISOString();
638
+ }
639
+
528
640
  // src/pricing/aws/aws-normalizer.ts
529
641
  function extractUsdPrice(priceDimensions) {
530
642
  for (const dim of Object.values(priceDimensions)) {
@@ -562,87 +674,131 @@ function extractFromTerms(onDemandTerms, defaultUnit) {
562
674
  }
563
675
  return { price: 0, unit: defaultUnit };
564
676
  }
565
- function normalizeAwsCompute(rawProduct, rawPrice, region) {
566
- const attrs = rawProduct?.attributes ?? {};
677
+ function normalize(rawPrice, region, effectiveDate, spec) {
567
678
  const terms = rawPrice?.terms?.OnDemand ?? {};
568
- const { price, unit } = extractFromTerms(terms, "Hrs");
679
+ const { price, unit } = extractFromTerms(terms, spec.defaultUnit);
569
680
  return {
570
681
  provider: "aws",
571
- service: "ec2",
572
- resource_type: attrs.instanceType ?? "unknown",
682
+ service: spec.service,
683
+ resource_type: spec.resourceType,
573
684
  region,
574
685
  unit,
575
686
  price_per_unit: price,
576
687
  currency: "USD",
688
+ description: spec.description,
689
+ attributes: {
690
+ ...spec.attributes,
691
+ pricing_source: "live"
692
+ },
693
+ effective_date: resolveEffectiveDate(effectiveDate)
694
+ };
695
+ }
696
+ function normalizeAwsCompute(rawProduct, rawPrice, region, effectiveDate) {
697
+ const attrs = rawProduct?.attributes ?? {};
698
+ return normalize(rawPrice, region, effectiveDate, {
699
+ service: "ec2",
700
+ defaultUnit: "Hrs",
701
+ resourceType: attrs.instanceType ?? "unknown",
577
702
  description: attrs.instanceType ? `AWS EC2 ${attrs.instanceType} (${attrs.operatingSystem ?? "Linux"})` : void 0,
578
703
  attributes: {
579
704
  instance_type: attrs.instanceType ?? "",
580
705
  vcpu: attrs.vcpu ?? "",
581
706
  memory: attrs.memory ?? "",
582
707
  operating_system: attrs.operatingSystem ?? "Linux",
583
- tenancy: attrs.tenancy ?? "Shared",
584
- pricing_source: "live"
585
- },
586
- effective_date: (/* @__PURE__ */ new Date()).toISOString()
587
- };
708
+ tenancy: attrs.tenancy ?? "Shared"
709
+ }
710
+ });
588
711
  }
589
- function normalizeAwsDatabase(rawProduct, rawPrice, region) {
712
+ function normalizeAwsDatabase(rawProduct, rawPrice, region, effectiveDate) {
590
713
  const attrs = rawProduct?.attributes ?? {};
591
- const terms = rawPrice?.terms?.OnDemand ?? {};
592
- const { price, unit } = extractFromTerms(terms, "Hrs");
593
- return {
594
- provider: "aws",
714
+ return normalize(rawPrice, region, effectiveDate, {
595
715
  service: "rds",
596
- resource_type: attrs.instanceType ?? "unknown",
597
- region,
598
- unit,
599
- price_per_unit: price,
600
- currency: "USD",
716
+ defaultUnit: "Hrs",
717
+ resourceType: attrs.instanceType ?? "unknown",
601
718
  description: attrs.instanceType ? `AWS RDS ${attrs.instanceType} (${attrs.databaseEngine ?? "MySQL"})` : void 0,
602
719
  attributes: {
603
720
  instance_type: attrs.instanceType ?? "",
604
721
  database_engine: attrs.databaseEngine ?? "",
605
722
  deployment_option: attrs.deploymentOption ?? "Single-AZ",
606
723
  vcpu: attrs.vcpu ?? "",
607
- memory: attrs.memory ?? "",
608
- pricing_source: "live"
609
- },
610
- effective_date: (/* @__PURE__ */ new Date()).toISOString()
611
- };
724
+ memory: attrs.memory ?? ""
725
+ }
726
+ });
612
727
  }
613
- function normalizeAwsStorage(rawProduct, rawPrice, region) {
728
+ function normalizeAwsStorage(rawProduct, rawPrice, region, effectiveDate) {
614
729
  const attrs = rawProduct?.attributes ?? {};
615
- const terms = rawPrice?.terms?.OnDemand ?? {};
616
- const { price, unit } = extractFromTerms(terms, "GB-Mo");
617
730
  const volumeType = attrs.volumeApiName ?? attrs.volumeType ?? "gp3";
618
- return {
619
- provider: "aws",
731
+ return normalize(rawPrice, region, effectiveDate, {
620
732
  service: "ebs",
621
- resource_type: volumeType,
622
- region,
623
- unit,
624
- price_per_unit: price,
625
- currency: "USD",
733
+ defaultUnit: "GB-Mo",
734
+ resourceType: volumeType,
626
735
  description: `AWS EBS ${volumeType} volume`,
627
736
  attributes: {
628
737
  volume_type: volumeType,
629
738
  max_iops: attrs.maxIopsvolume ?? "",
630
- max_throughput: attrs.maxThroughputvolume ?? "",
631
- pricing_source: "live"
632
- },
633
- effective_date: (/* @__PURE__ */ new Date()).toISOString()
634
- };
739
+ max_throughput: attrs.maxThroughputvolume ?? ""
740
+ }
741
+ });
635
742
  }
636
743
 
637
744
  // src/pricing/fetch-utils.ts
745
+ var DEFAULT_MAX_RESPONSE_BYTES = 100 * 1024 * 1024;
746
+ var DEFAULT_TIMEOUT_MS = 6e4;
747
+ function assertUrlAllowed(url, options) {
748
+ let parsed;
749
+ try {
750
+ parsed = new URL(url);
751
+ } catch {
752
+ throw new Error(`fetchWithRetry: invalid URL`);
753
+ }
754
+ const requireHttps = options.requireHttps ?? true;
755
+ if (requireHttps && parsed.protocol !== "https:") {
756
+ throw new Error(`fetchWithRetry: rejected non-https URL (scheme=${parsed.protocol})`);
757
+ }
758
+ if (options.allowedHosts && options.allowedHosts.length > 0) {
759
+ if (!options.allowedHosts.includes(parsed.host)) {
760
+ throw new Error(`fetchWithRetry: host ${parsed.host} not in allowlist`);
761
+ }
762
+ }
763
+ return parsed;
764
+ }
765
+ function capResponseBody(res, maxBytes) {
766
+ if (!res.body) return res;
767
+ if (!Number.isFinite(maxBytes)) return res;
768
+ let seen = 0;
769
+ const limiter = new TransformStream({
770
+ transform(chunk, controller) {
771
+ seen += chunk.byteLength;
772
+ if (seen > maxBytes) {
773
+ controller.error(new Error(`fetchWithRetry: response body exceeded ${maxBytes} bytes`));
774
+ return;
775
+ }
776
+ controller.enqueue(chunk);
777
+ }
778
+ });
779
+ const limited = res.body.pipeThrough(limiter);
780
+ return new Response(limited, {
781
+ status: res.status,
782
+ statusText: res.statusText,
783
+ headers: res.headers
784
+ });
785
+ }
786
+ function withDefaultSignal(init, timeoutMs) {
787
+ if (init?.signal) return init;
788
+ return { ...init ?? {}, signal: AbortSignal.timeout(timeoutMs) };
789
+ }
638
790
  async function fetchWithRetry(url, init, options = {}) {
639
791
  const maxRetries = options.maxRetries ?? 3;
640
792
  const baseDelay = options.baseDelay ?? 1e3;
641
793
  const maxDelay = options.maxDelay ?? 1e4;
794
+ const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
795
+ const defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
796
+ assertUrlAllowed(url, options);
797
+ const safeInit = withDefaultSignal(init, defaultTimeoutMs);
642
798
  let lastError;
643
799
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
644
800
  try {
645
- const res = await fetch(url, init);
801
+ const res = await fetch(url, safeInit);
646
802
  if (res.status === 429 || res.status >= 500 && res.status < 600) {
647
803
  if (attempt < maxRetries) {
648
804
  const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
@@ -656,7 +812,7 @@ async function fetchWithRetry(url, init, options = {}) {
656
812
  continue;
657
813
  }
658
814
  }
659
- return res;
815
+ return capResponseBody(res, maxResponseBytes);
660
816
  } catch (err) {
661
817
  lastError = err;
662
818
  if (attempt < maxRetries) {
@@ -677,13 +833,14 @@ async function fetchWithRetry(url, init, options = {}) {
677
833
  }
678
834
  }
679
835
  }
680
- throw lastError ?? new Error(`fetchWithRetry: all ${maxRetries} retries failed for ${url}`);
836
+ if (lastError instanceof Error) throw lastError;
837
+ throw new Error(`fetchWithRetry: all ${maxRetries} retries failed for ${url}`);
681
838
  }
682
839
  function sleep(ms) {
683
840
  if (process.env.NODE_ENV === "test" || process.env.VITEST) {
684
841
  return Promise.resolve();
685
842
  }
686
- return new Promise((resolve2) => setTimeout(resolve2, ms));
843
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
687
844
  }
688
845
  var CIRCUIT_FAILURE_THRESHOLD = 5;
689
846
  var CIRCUIT_OPEN_DURATION_MS = 5 * 60 * 1e3;
@@ -901,6 +1058,11 @@ var EC2_BASE_PRICES = {
901
1058
  "m7i.large": 0.1008,
902
1059
  "m7i.xlarge": 0.2016,
903
1060
  "m7i.2xlarge": 0.4032,
1061
+ "m7i-flex.large": 0.0958,
1062
+ "m7i-flex.xlarge": 0.1915,
1063
+ "m7i-flex.2xlarge": 0.383,
1064
+ "m7i-flex.4xlarge": 0.7661,
1065
+ "m7i-flex.8xlarge": 1.5322,
904
1066
  "m7g.large": 0.0816,
905
1067
  "m7g.xlarge": 0.1632,
906
1068
  "m7g.2xlarge": 0.3264,
@@ -918,10 +1080,10 @@ var EC2_BASE_PRICES = {
918
1080
  "c6g.xlarge": 0.136,
919
1081
  "c6g.2xlarge": 0.272,
920
1082
  "c6g.4xlarge": 0.544,
921
- "c7i.large": 0.089,
922
- "c7i.xlarge": 0.178,
1083
+ "c7i.large": 0.0893,
1084
+ "c7i.xlarge": 0.1785,
923
1085
  "c7i.2xlarge": 0.357,
924
- "c7g.large": 0.072,
1086
+ "c7g.large": 0.0725,
925
1087
  "c7g.xlarge": 0.145,
926
1088
  "c7g.2xlarge": 0.29,
927
1089
  "c7g.4xlarge": 0.58,
@@ -934,65 +1096,63 @@ var EC2_BASE_PRICES = {
934
1096
  "r6i.large": 0.126,
935
1097
  "r6i.xlarge": 0.252,
936
1098
  "r6i.2xlarge": 0.504,
937
- "r6g.large": 0.101,
938
- "r6g.xlarge": 0.202,
939
- "r6g.2xlarge": 0.403,
940
- "r6g.4xlarge": 0.806,
941
- "r7i.large": 0.133,
942
- "r7i.xlarge": 0.266,
943
- "r7i.2xlarge": 0.532,
944
- "r7g.large": 0.107,
945
- "r7g.xlarge": 0.214,
946
- "r7g.2xlarge": 0.428,
947
- "r7g.4xlarge": 0.856,
948
- // 8th-gen Graviton4 families
949
- "m8g.large": 0.077,
950
- "m8g.xlarge": 0.154,
951
- "m8g.2xlarge": 0.308,
952
- "m8g.4xlarge": 0.616,
953
- "c8g.large": 0.068,
954
- "c8g.xlarge": 0.136,
955
- "c8g.2xlarge": 0.272,
956
- "c8g.4xlarge": 0.544,
957
- "r8g.large": 0.101,
958
- "r8g.xlarge": 0.202,
959
- "r8g.2xlarge": 0.404,
960
- "r8g.4xlarge": 0.808,
961
- // GPU instances
1099
+ "r6g.large": 0.1008,
1100
+ "r6g.xlarge": 0.2016,
1101
+ "r6g.2xlarge": 0.4032,
1102
+ "r6g.4xlarge": 0.8064,
1103
+ "r7i.large": 0.1323,
1104
+ "r7i.xlarge": 0.2646,
1105
+ "r7i.2xlarge": 0.5292,
1106
+ "r7g.large": 0.1071,
1107
+ "r7g.xlarge": 0.2142,
1108
+ "r7g.2xlarge": 0.4284,
1109
+ "r7g.4xlarge": 0.8568,
1110
+ "m8g.large": 0.0898,
1111
+ "m8g.xlarge": 0.1795,
1112
+ "m8g.2xlarge": 0.359,
1113
+ "m8g.4xlarge": 0.7181,
1114
+ "c8g.large": 0.0798,
1115
+ "c8g.xlarge": 0.1595,
1116
+ "c8g.2xlarge": 0.319,
1117
+ "c8g.4xlarge": 0.6381,
1118
+ "r8g.large": 0.1178,
1119
+ "r8g.xlarge": 0.2356,
1120
+ "r8g.2xlarge": 0.4713,
1121
+ "r8g.4xlarge": 0.9426,
962
1122
  "g5.xlarge": 1.006,
963
1123
  "g5.2xlarge": 1.212,
964
1124
  "g5.4xlarge": 1.624,
965
1125
  "g5.8xlarge": 2.448,
966
1126
  "g5.12xlarge": 5.672,
967
- "p4d.24xlarge": 32.7726
1127
+ "p4d.24xlarge": 21.9576
968
1128
  };
969
1129
  var RDS_BASE_PRICES = {
970
- "db.t3.micro": 0.017,
971
- "db.t3.small": 0.034,
972
- "db.t3.medium": 0.068,
973
- "db.t3.large": 0.136,
1130
+ "db.t3.micro": 0.018,
1131
+ "db.t3.small": 0.036,
1132
+ "db.t3.medium": 0.072,
1133
+ "db.t3.large": 0.145,
974
1134
  "db.t4g.micro": 0.016,
975
1135
  "db.t4g.small": 0.032,
976
1136
  "db.t4g.medium": 0.065,
977
1137
  "db.t4g.large": 0.129,
978
1138
  "db.t4g.xlarge": 0.258,
979
- "db.m5.large": 0.171,
980
- "db.m5.xlarge": 0.342,
981
- "db.m6g.large": 0.154,
982
- "db.m6g.xlarge": 0.308,
983
- "db.m6g.2xlarge": 0.616,
984
- "db.m6i.large": 0.171,
985
- "db.m6i.xlarge": 0.342,
986
- "db.m6i.2xlarge": 0.684,
1139
+ "db.m5.large": 0.178,
1140
+ "db.m5.xlarge": 0.356,
1141
+ "db.m6g.large": 0.159,
1142
+ "db.m6g.xlarge": 0.318,
1143
+ "db.m6g.2xlarge": 0.636,
1144
+ "db.m6i.large": 0.178,
1145
+ "db.m6i.xlarge": 0.356,
1146
+ "db.m6i.2xlarge": 0.712,
987
1147
  "db.m7g.large": 0.168,
988
- "db.m7g.xlarge": 0.336,
989
- "db.m7g.2xlarge": 0.672,
1148
+ "db.m7g.xlarge": 0.337,
1149
+ "db.m7g.2xlarge": 0.674,
990
1150
  "db.r5.large": 0.25,
991
1151
  "db.r5.xlarge": 0.5,
992
1152
  "db.r5.2xlarge": 1,
993
- "db.r6g.large": 0.218,
994
- "db.r6g.xlarge": 0.437,
995
- "db.r6g.2xlarge": 0.874,
1153
+ "db.r6g.large": 0.225,
1154
+ "db.r6g.xlarge": 0.45,
1155
+ "db.r6g.2xlarge": 0.899,
996
1156
  "db.r6i.large": 0.25,
997
1157
  "db.r6i.xlarge": 0.5,
998
1158
  "db.r6i.2xlarge": 1
@@ -1012,6 +1172,24 @@ var NAT_PER_GB = 0.045;
1012
1172
  var EKS_HOURLY = 0.1;
1013
1173
  var CACHE_TTL = 86400;
1014
1174
  var BULK_PRICING_BASE = "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws";
1175
+ var AWS_PRICING_HOST = "pricing.us-east-1.amazonaws.com";
1176
+ var AWS_REGION_RE = /^[a-z]{2}(?:-(?:gov|iso|isob))?-[a-z]+-\d+$/;
1177
+ var AWS_PRICING_SERVICES = /* @__PURE__ */ new Set([
1178
+ "AmazonEC2",
1179
+ "AmazonRDS",
1180
+ "AmazonElastiCache",
1181
+ "AmazonRedshift"
1182
+ ]);
1183
+ function assertValidAwsRegion(region) {
1184
+ if (typeof region !== "string" || !AWS_REGION_RE.test(region)) {
1185
+ throw new Error(`invalid AWS region: ${JSON.stringify(region).slice(0, 64)}`);
1186
+ }
1187
+ }
1188
+ function assertValidAwsPricingService(service) {
1189
+ if (typeof service !== "string" || !AWS_PRICING_SERVICES.has(service)) {
1190
+ throw new Error(`invalid AWS pricing service: ${JSON.stringify(service).slice(0, 64)}`);
1191
+ }
1192
+ }
1015
1193
  var SIZE_ORDER = [
1016
1194
  "nano",
1017
1195
  "micro",
@@ -1028,11 +1206,12 @@ var SIZE_ORDER = [
1028
1206
  "32xlarge",
1029
1207
  "48xlarge"
1030
1208
  ];
1031
- function regionMultiplier(region) {
1032
- const multipliers = getRegionPriceMultipliers();
1033
- const mult = multipliers.aws[region.toLowerCase()];
1209
+
1210
+ // src/pricing/region-multiplier.ts
1211
+ function regionMultiplier(provider, region) {
1212
+ const mult = getRegionPriceMultipliers()[provider][region.toLowerCase()];
1034
1213
  if (mult === void 0) {
1035
- logger.warn("region-multiplier: unknown AWS region, defaulting to 1.0x", { region });
1214
+ logger.warn("region-multiplier: unknown region, defaulting to 1.0x", { provider, region });
1036
1215
  return 1;
1037
1216
  }
1038
1217
  return mult;
@@ -1041,13 +1220,16 @@ function regionMultiplier(region) {
1041
1220
  // src/pricing/aws/bulk-loader.ts
1042
1221
  var AwsBulkLoader = class {
1043
1222
  cache;
1223
+ /** Cache TTL for pricing writes (seconds); from config.cache.ttl_seconds. */
1224
+ ttlSeconds;
1044
1225
  /**
1045
1226
  * Deduplicates concurrent streaming CSV fetches for the same region.
1046
1227
  * Keyed by region; value is the in-flight Promise<boolean>.
1047
1228
  */
1048
1229
  ec2CsvFetchInProgress = /* @__PURE__ */ new Map();
1049
- constructor(cache) {
1230
+ constructor(cache, ttlSeconds = CACHE_TTL) {
1050
1231
  this.cache = cache;
1232
+ this.ttlSeconds = ttlSeconds;
1051
1233
  }
1052
1234
  // -------------------------------------------------------------------------
1053
1235
  // Public API
@@ -1074,7 +1256,7 @@ var AwsBulkLoader = class {
1074
1256
  if (bulk) {
1075
1257
  const result = this.extractEc2Price(bulk, instanceType, region, os);
1076
1258
  if (result) {
1077
- this.cache.set(cacheKey, result, "aws", "ec2", region, CACHE_TTL);
1259
+ this.cache.set(cacheKey, result, "aws", "ec2", region, this.ttlSeconds);
1078
1260
  return result;
1079
1261
  }
1080
1262
  }
@@ -1096,7 +1278,7 @@ var AwsBulkLoader = class {
1096
1278
  if (bulk) {
1097
1279
  const result = this.extractRdsPrice(bulk, instanceClass, region, engine);
1098
1280
  if (result) {
1099
- this.cache.set(cacheKey, result, "aws", "rds", region, CACHE_TTL);
1281
+ this.cache.set(cacheKey, result, "aws", "rds", region, this.ttlSeconds);
1100
1282
  return result;
1101
1283
  }
1102
1284
  }
@@ -1118,7 +1300,7 @@ var AwsBulkLoader = class {
1118
1300
  if (bulk) {
1119
1301
  const result = this.extractEbsPrice(bulk, volumeType, region);
1120
1302
  if (result) {
1121
- this.cache.set(cacheKey, result, "aws", "ebs", region, CACHE_TTL);
1303
+ this.cache.set(cacheKey, result, "aws", "ebs", region, this.ttlSeconds);
1122
1304
  return result;
1123
1305
  }
1124
1306
  }
@@ -1131,9 +1313,9 @@ var AwsBulkLoader = class {
1131
1313
  }
1132
1314
  return this.fallbackStoragePrice(volumeType, region, cacheKey);
1133
1315
  }
1134
- async getLoadBalancerPrice(region) {
1135
- const multiplier = regionMultiplier(region);
1136
- return {
1316
+ getLoadBalancerPrice(region) {
1317
+ const multiplier = regionMultiplier("aws", region);
1318
+ return Promise.resolve({
1137
1319
  provider: "aws",
1138
1320
  service: "elb",
1139
1321
  resource_type: "alb",
@@ -1147,11 +1329,11 @@ var AwsBulkLoader = class {
1147
1329
  pricing_model: "fixed_plus_lcu"
1148
1330
  },
1149
1331
  effective_date: (/* @__PURE__ */ new Date()).toISOString()
1150
- };
1332
+ });
1151
1333
  }
1152
- async getNatGatewayPrice(region) {
1153
- const multiplier = regionMultiplier(region);
1154
- return {
1334
+ getNatGatewayPrice(region) {
1335
+ const multiplier = regionMultiplier("aws", region);
1336
+ return Promise.resolve({
1155
1337
  provider: "aws",
1156
1338
  service: "vpc",
1157
1339
  resource_type: "nat-gateway",
@@ -1164,11 +1346,11 @@ var AwsBulkLoader = class {
1164
1346
  per_gb_price: String(NAT_PER_GB * multiplier)
1165
1347
  },
1166
1348
  effective_date: (/* @__PURE__ */ new Date()).toISOString()
1167
- };
1349
+ });
1168
1350
  }
1169
- async getKubernetesPrice(region) {
1170
- const multiplier = regionMultiplier(region);
1171
- return {
1351
+ getKubernetesPrice(region) {
1352
+ const multiplier = regionMultiplier("aws", region);
1353
+ return Promise.resolve({
1172
1354
  provider: "aws",
1173
1355
  service: "eks",
1174
1356
  resource_type: "cluster",
@@ -1179,7 +1361,7 @@ var AwsBulkLoader = class {
1179
1361
  description: "AWS EKS control plane (per cluster/hour)",
1180
1362
  attributes: {},
1181
1363
  effective_date: (/* @__PURE__ */ new Date()).toISOString()
1182
- };
1364
+ });
1183
1365
  }
1184
1366
  // -------------------------------------------------------------------------
1185
1367
  // Internal helpers – EC2 CSV streaming
@@ -1209,7 +1391,8 @@ var AwsBulkLoader = class {
1209
1391
  return promise;
1210
1392
  }
1211
1393
  async _doFetchAndCacheEc2CsvPrices(region) {
1212
- const url = `${BULK_PRICING_BASE}/AmazonEC2/current/${region}/index.csv`;
1394
+ assertValidAwsRegion(region);
1395
+ const url = `${BULK_PRICING_BASE}/AmazonEC2/current/${encodeURIComponent(region)}/index.csv`;
1213
1396
  logger.debug("Streaming AWS EC2 CSV pricing", { url, region });
1214
1397
  const controller = new AbortController();
1215
1398
  const timeoutId = setTimeout(() => controller.abort(), 12e4);
@@ -1220,7 +1403,11 @@ var AwsBulkLoader = class {
1220
1403
  {
1221
1404
  maxRetries: 1,
1222
1405
  baseDelay: 500,
1223
- maxDelay: 2e3
1406
+ maxDelay: 2e3,
1407
+ allowedHosts: [AWS_PRICING_HOST],
1408
+ // AWS EC2 CSVs run ~267 MiB today; allow headroom for growth while
1409
+ // still hard-bounding at 512 MiB to prevent adversarial OOM.
1410
+ maxResponseBytes: 512 * 1024 * 1024
1224
1411
  }
1225
1412
  );
1226
1413
  if (!res.ok) {
@@ -1247,7 +1434,7 @@ var AwsBulkLoader = class {
1247
1434
  let headerFound = false;
1248
1435
  let leftover = "";
1249
1436
  let cachedCount = 0;
1250
- const effectiveDate = (/* @__PURE__ */ new Date()).toISOString();
1437
+ let effectiveDate = (/* @__PURE__ */ new Date()).toISOString();
1251
1438
  while (true) {
1252
1439
  const { value, done } = await reader.read();
1253
1440
  if (done) break;
@@ -1258,6 +1445,17 @@ var AwsBulkLoader = class {
1258
1445
  const line = rawLine.trimEnd();
1259
1446
  if (!line) continue;
1260
1447
  if (!headerFound) {
1448
+ if (line.startsWith('"Publication Date"') || line.startsWith("Publication Date")) {
1449
+ const metaFields = parseCsvLine(line);
1450
+ const rawDate = (metaFields[1] ?? "").trim();
1451
+ if (rawDate) {
1452
+ const parsed = new Date(rawDate);
1453
+ if (!isNaN(parsed.getTime())) {
1454
+ effectiveDate = parsed.toISOString();
1455
+ }
1456
+ }
1457
+ continue;
1458
+ }
1261
1459
  if (line.startsWith('"SKU"') || line.startsWith("SKU")) {
1262
1460
  const headers = parseCsvLine(line);
1263
1461
  for (let h = 0; h < headers.length; h++) {
@@ -1330,7 +1528,7 @@ var AwsBulkLoader = class {
1330
1528
  },
1331
1529
  effective_date: effectiveDate
1332
1530
  };
1333
- this.cache.set(csvCacheKey, normalized, "aws", "ec2", region, CACHE_TTL);
1531
+ this.cache.set(csvCacheKey, normalized, "aws", "ec2", region, this.ttlSeconds);
1334
1532
  cachedCount++;
1335
1533
  }
1336
1534
  }
@@ -1362,12 +1560,12 @@ var AwsBulkLoader = class {
1362
1560
  tenancy: "Shared",
1363
1561
  pricing_source: "live"
1364
1562
  },
1365
- effective_date: (/* @__PURE__ */ new Date()).toISOString()
1563
+ effective_date: effectiveDate
1366
1564
  },
1367
1565
  "aws",
1368
1566
  "ec2",
1369
1567
  region,
1370
- CACHE_TTL
1568
+ this.ttlSeconds
1371
1569
  );
1372
1570
  cachedCount++;
1373
1571
  }
@@ -1399,14 +1597,19 @@ var AwsBulkLoader = class {
1399
1597
  // Internal helpers – live API fetch
1400
1598
  // -------------------------------------------------------------------------
1401
1599
  async fetchBulkPricing(service, region) {
1402
- const url = `${BULK_PRICING_BASE}/${service}/current/${region}/index.json`;
1600
+ assertValidAwsPricingService(service);
1601
+ assertValidAwsRegion(region);
1602
+ const url = `${BULK_PRICING_BASE}/${encodeURIComponent(service)}/current/${encodeURIComponent(region)}/index.json`;
1403
1603
  logger.debug("Fetching AWS bulk pricing", { url });
1404
1604
  const res = await fetchWithRetryAndCircuitBreaker(
1405
1605
  url,
1406
1606
  {
1407
1607
  signal: AbortSignal.timeout(3e4)
1408
1608
  },
1409
- { maxRetries: 0 }
1609
+ {
1610
+ maxRetries: 0,
1611
+ allowedHosts: [AWS_PRICING_HOST]
1612
+ }
1410
1613
  );
1411
1614
  if (!res.ok) {
1412
1615
  throw new Error(`HTTP ${res.status} from ${url}`);
@@ -1422,6 +1625,7 @@ var AwsBulkLoader = class {
1422
1625
  extractEc2Price(bulk, instanceType, region, os) {
1423
1626
  const products = bulk?.products ?? {};
1424
1627
  const onDemand = bulk?.terms?.OnDemand ?? {};
1628
+ const publicationDate = bulk?.publicationDate;
1425
1629
  const sortedEntries = Object.entries(products).sort(([a], [b]) => a.localeCompare(b));
1426
1630
  for (const [sku, product] of sortedEntries) {
1427
1631
  const attrs = product?.attributes ?? {};
@@ -1431,7 +1635,8 @@ var AwsBulkLoader = class {
1431
1635
  return normalizeAwsCompute(
1432
1636
  product,
1433
1637
  { terms: { OnDemand: { [sku]: priceTerms } } },
1434
- region
1638
+ region,
1639
+ publicationDate
1435
1640
  );
1436
1641
  }
1437
1642
  }
@@ -1441,6 +1646,7 @@ var AwsBulkLoader = class {
1441
1646
  extractRdsPrice(bulk, instanceClass, region, engine) {
1442
1647
  const products = bulk?.products ?? {};
1443
1648
  const onDemand = bulk?.terms?.OnDemand ?? {};
1649
+ const publicationDate = bulk?.publicationDate;
1444
1650
  const sortedEntries = Object.entries(products).sort(([a], [b]) => a.localeCompare(b));
1445
1651
  for (const [sku, product] of sortedEntries) {
1446
1652
  const attrs = product?.attributes ?? {};
@@ -1450,7 +1656,8 @@ var AwsBulkLoader = class {
1450
1656
  return normalizeAwsDatabase(
1451
1657
  product,
1452
1658
  { terms: { OnDemand: { [sku]: priceTerms } } },
1453
- region
1659
+ region,
1660
+ publicationDate
1454
1661
  );
1455
1662
  }
1456
1663
  }
@@ -1460,6 +1667,7 @@ var AwsBulkLoader = class {
1460
1667
  extractEbsPrice(bulk, volumeType, region) {
1461
1668
  const products = bulk?.products ?? {};
1462
1669
  const onDemand = bulk?.terms?.OnDemand ?? {};
1670
+ const publicationDate = bulk?.publicationDate;
1463
1671
  const sortedEntries = Object.entries(products).sort(([a], [b]) => a.localeCompare(b));
1464
1672
  for (const [sku, product] of sortedEntries) {
1465
1673
  const attrs = product?.attributes ?? {};
@@ -1470,7 +1678,8 @@ var AwsBulkLoader = class {
1470
1678
  return normalizeAwsStorage(
1471
1679
  product,
1472
1680
  { terms: { OnDemand: { [sku]: priceTerms } } },
1473
- region
1681
+ region,
1682
+ publicationDate
1474
1683
  );
1475
1684
  }
1476
1685
  }
@@ -1486,7 +1695,7 @@ var AwsBulkLoader = class {
1486
1695
  logger.warn("No fallback price found for EC2 instance type", { instanceType });
1487
1696
  return null;
1488
1697
  }
1489
- const multiplier = regionMultiplier(region);
1698
+ const multiplier = regionMultiplier("aws", region);
1490
1699
  const result = {
1491
1700
  provider: "aws",
1492
1701
  service: "ec2",
@@ -1504,7 +1713,7 @@ var AwsBulkLoader = class {
1504
1713
  },
1505
1714
  effective_date: (/* @__PURE__ */ new Date()).toISOString()
1506
1715
  };
1507
- this.cache.set(cacheKey, result, "aws", "ec2", region, CACHE_TTL);
1716
+ this.cache.set(cacheKey, result, "aws", "ec2", region, this.ttlSeconds);
1508
1717
  return result;
1509
1718
  }
1510
1719
  fallbackDatabasePrice(instanceClass, region, engine, cacheKey) {
@@ -1513,7 +1722,7 @@ var AwsBulkLoader = class {
1513
1722
  logger.warn("No fallback price found for RDS instance class", { instanceClass });
1514
1723
  return null;
1515
1724
  }
1516
- const multiplier = regionMultiplier(region);
1725
+ const multiplier = regionMultiplier("aws", region);
1517
1726
  const result = {
1518
1727
  provider: "aws",
1519
1728
  service: "rds",
@@ -1531,7 +1740,7 @@ var AwsBulkLoader = class {
1531
1740
  },
1532
1741
  effective_date: (/* @__PURE__ */ new Date()).toISOString()
1533
1742
  };
1534
- this.cache.set(cacheKey, result, "aws", "rds", region, CACHE_TTL);
1743
+ this.cache.set(cacheKey, result, "aws", "rds", region, this.ttlSeconds);
1535
1744
  return result;
1536
1745
  }
1537
1746
  fallbackStoragePrice(volumeType, region, cacheKey) {
@@ -1540,7 +1749,7 @@ var AwsBulkLoader = class {
1540
1749
  logger.warn("No fallback price found for EBS volume type", { volumeType });
1541
1750
  return null;
1542
1751
  }
1543
- const multiplier = regionMultiplier(region);
1752
+ const multiplier = regionMultiplier("aws", region);
1544
1753
  const result = {
1545
1754
  provider: "aws",
1546
1755
  service: "ebs",
@@ -1556,74 +1765,411 @@ var AwsBulkLoader = class {
1556
1765
  },
1557
1766
  effective_date: (/* @__PURE__ */ new Date()).toISOString()
1558
1767
  };
1559
- this.cache.set(cacheKey, result, "aws", "ebs", region, CACHE_TTL);
1768
+ this.cache.set(cacheKey, result, "aws", "ebs", region, this.ttlSeconds);
1560
1769
  return result;
1561
1770
  }
1562
1771
  };
1563
1772
 
1564
- // src/pricing/azure/azure-normalizer.ts
1565
- function normalizeAzureCompute(item) {
1566
- return {
1567
- provider: "azure",
1568
- service: "virtual-machines",
1569
- resource_type: item.skuName ?? item.armSkuName ?? "unknown",
1570
- region: item.armRegionName ?? item.location ?? "",
1571
- unit: item.unitOfMeasure ?? "1 Hour",
1572
- price_per_unit: item.retailPrice ?? item.unitPrice ?? 0,
1573
- currency: item.currencyCode ?? "USD",
1574
- tier: item.tierMinimumUnits !== void 0 ? String(item.tierMinimumUnits) : void 0,
1575
- description: item.productName ?? void 0,
1576
- attributes: {
1577
- sku_name: item.skuName ?? "",
1578
- arm_sku_name: item.armSkuName ?? "",
1579
- service_name: item.serviceName ?? "Virtual Machines",
1580
- product_name: item.productName ?? "",
1581
- meter_name: item.meterName ?? "",
1582
- pricing_source: "live"
1583
- },
1584
- effective_date: item.effectiveStartDate ?? (/* @__PURE__ */ new Date()).toISOString()
1585
- };
1586
- }
1587
- function normalizeAzureDatabase(item) {
1588
- return {
1589
- provider: "azure",
1590
- service: "azure-database",
1591
- resource_type: item.skuName ?? item.armSkuName ?? "unknown",
1592
- region: item.armRegionName ?? item.location ?? "",
1593
- unit: item.unitOfMeasure ?? "1 Hour",
1594
- price_per_unit: item.retailPrice ?? item.unitPrice ?? 0,
1595
- currency: item.currencyCode ?? "USD",
1596
- description: item.productName ?? void 0,
1597
- attributes: {
1598
- sku_name: item.skuName ?? "",
1599
- service_name: item.serviceName ?? "",
1600
- product_name: item.productName ?? "",
1601
- meter_name: item.meterName ?? "",
1602
- pricing_source: "live"
1603
- },
1604
- effective_date: item.effectiveStartDate ?? (/* @__PURE__ */ new Date()).toISOString()
1605
- };
1606
- }
1607
- function normalizeAzureStorage(item) {
1608
- return {
1609
- provider: "azure",
1610
- service: "managed-disks",
1611
- resource_type: item.skuName ?? item.armSkuName ?? "unknown",
1612
- region: item.armRegionName ?? item.location ?? "",
1613
- unit: item.unitOfMeasure ?? "1 GiB/Month",
1614
- price_per_unit: item.retailPrice ?? item.unitPrice ?? 0,
1615
- currency: item.currencyCode ?? "USD",
1616
- description: item.productName ?? void 0,
1617
- attributes: {
1618
- sku_name: item.skuName ?? "",
1619
- service_name: item.serviceName ?? "",
1620
- product_name: item.productName ?? "",
1621
- meter_name: item.meterName ?? "",
1773
+ // src/pricing/aws/spot-client.ts
1774
+ var SPOT_ADVISOR_URL = "https://website.spot.ec2.aws.a2z.com/spot.json";
1775
+ var SPOT_ADVISOR_CACHE_KEY = "aws/spot-advisor/v1";
1776
+ var AwsSpotClient = class {
1777
+ cache;
1778
+ /** Cache TTL for pricing writes (seconds); from config.cache.ttl_seconds. */
1779
+ ttlSeconds;
1780
+ /** In-flight fetch deduplication. */
1781
+ inflight = null;
1782
+ constructor(cache, ttlSeconds = CACHE_TTL) {
1783
+ this.cache = cache;
1784
+ this.ttlSeconds = ttlSeconds;
1785
+ }
1786
+ /**
1787
+ * Get the spot discount factor (0–1) for an instance type in a region.
1788
+ *
1789
+ * Returns a fraction representing the **portion of on-demand you pay** — i.e.
1790
+ * `hourly_spot = hourly_on_demand * factor`. Returns null when the live data
1791
+ * is unavailable or doesn't cover the given instance/region combination;
1792
+ * callers should fall back to static family-based estimates in that case.
1793
+ */
1794
+ async getSpotFactor(instanceType, region, os = "Linux") {
1795
+ try {
1796
+ const doc = await this.loadDocument();
1797
+ if (!doc) return null;
1798
+ const regionData = doc.spot_advisor?.[region];
1799
+ if (!regionData) return null;
1800
+ const osKey = os === "Windows" ? "Windows" : "Linux";
1801
+ const osData = regionData[osKey];
1802
+ if (!osData) return null;
1803
+ const entry = osData[instanceType];
1804
+ if (!entry || typeof entry.s !== "number") return null;
1805
+ const savingsPct = Math.max(0, Math.min(95, entry.s));
1806
+ const factor = 1 - savingsPct / 100;
1807
+ return Math.max(0.05, factor);
1808
+ } catch (err) {
1809
+ logger.debug("AwsSpotClient.getSpotFactor failed", {
1810
+ instanceType,
1811
+ region,
1812
+ err: err instanceof Error ? err.message : String(err)
1813
+ });
1814
+ return null;
1815
+ }
1816
+ }
1817
+ /**
1818
+ * Fetch and cache the full Spot Advisor document. Uses the shared SQLite
1819
+ * cache with the same TTL as other AWS bulk data. Deduplicates concurrent
1820
+ * callers via an in-flight promise.
1821
+ */
1822
+ loadDocument() {
1823
+ const cached = this.cache.get(SPOT_ADVISOR_CACHE_KEY);
1824
+ if (cached) return Promise.resolve(cached);
1825
+ if (this.inflight) return this.inflight;
1826
+ this.inflight = this.doFetchDocument().finally(() => {
1827
+ this.inflight = null;
1828
+ });
1829
+ return this.inflight;
1830
+ }
1831
+ async doFetchDocument() {
1832
+ logger.debug("Fetching AWS Spot Advisor JSON", { url: SPOT_ADVISOR_URL });
1833
+ try {
1834
+ const res = await fetchWithRetryAndCircuitBreaker(
1835
+ SPOT_ADVISOR_URL,
1836
+ { signal: AbortSignal.timeout(2e4) },
1837
+ { maxRetries: 1, baseDelay: 500, maxDelay: 2e3 }
1838
+ );
1839
+ if (!res.ok) {
1840
+ logger.debug("AWS Spot Advisor fetch returned non-OK", { status: res.status });
1841
+ return null;
1842
+ }
1843
+ const doc = await res.json();
1844
+ if (!doc || typeof doc.spot_advisor !== "object") {
1845
+ logger.debug("AWS Spot Advisor response missing spot_advisor key");
1846
+ return null;
1847
+ }
1848
+ this.cache.set(SPOT_ADVISOR_CACHE_KEY, doc, "aws", "spot-advisor", "global", this.ttlSeconds);
1849
+ return doc;
1850
+ } catch (err) {
1851
+ logger.debug("AWS Spot Advisor fetch failed", {
1852
+ err: err instanceof Error ? err.message : String(err)
1853
+ });
1854
+ return null;
1855
+ }
1856
+ }
1857
+ };
1858
+
1859
+ // src/pricing/aws/reserved-client.ts
1860
+ var HOURS_IN_TERM = {
1861
+ "1yr": 24 * 365,
1862
+ "3yr": 24 * 365 * 3
1863
+ };
1864
+ function extractRiRatesFromBulk(bulk, instanceType, os = "Linux") {
1865
+ const products = bulk?.products ?? {};
1866
+ const onDemand = bulk?.terms?.OnDemand ?? {};
1867
+ const reserved = bulk?.terms?.Reserved ?? {};
1868
+ let targetSku = null;
1869
+ for (const [sku, product] of Object.entries(products)) {
1870
+ const attrs = product?.attributes ?? {};
1871
+ if (attrs.instanceType === instanceType && (attrs.operatingSystem ?? "").toLowerCase() === os.toLowerCase() && attrs.tenancy === "Shared" && attrs.capacitystatus === "Used") {
1872
+ targetSku = sku;
1873
+ break;
1874
+ }
1875
+ }
1876
+ if (!targetSku) return [];
1877
+ const onDemandHourly = firstHourlyPrice(onDemand[targetSku]);
1878
+ if (!onDemandHourly || onDemandHourly <= 0) return [];
1879
+ const reservedOffers = reserved[targetSku];
1880
+ if (!reservedOffers) return [];
1881
+ const rates = [];
1882
+ for (const offer of Object.values(reservedOffers)) {
1883
+ const lease = offer.termAttributes?.LeaseContractLength;
1884
+ const purchase = offer.termAttributes?.PurchaseOption;
1885
+ const term = normalizeTerm(lease);
1886
+ const payment = normalizePayment(purchase);
1887
+ if (!term || !payment) continue;
1888
+ const effectiveHourly = computeEffectiveHourly(offer, term);
1889
+ if (effectiveHourly === null) continue;
1890
+ const rate = 1 - effectiveHourly / onDemandHourly;
1891
+ if (rate <= 0 || rate >= 0.9) continue;
1892
+ rates.push({ term, payment, rate });
1893
+ }
1894
+ return rates;
1895
+ }
1896
+ var AwsReservedClient = class {
1897
+ cache;
1898
+ /** Cache TTL for pricing writes (seconds); from config.cache.ttl_seconds. */
1899
+ ttlSeconds;
1900
+ constructor(cache, ttlSeconds = CACHE_TTL) {
1901
+ this.cache = cache;
1902
+ this.ttlSeconds = ttlSeconds;
1903
+ }
1904
+ async getRiRates(service, instanceType, region, os = "Linux") {
1905
+ if (service === "AmazonEC2") {
1906
+ logger.debug(
1907
+ "AwsReservedClient: refusing AmazonEC2 (bulk JSON too large for in-memory parse)",
1908
+ {
1909
+ instanceType,
1910
+ region
1911
+ }
1912
+ );
1913
+ return null;
1914
+ }
1915
+ const cacheKey = `aws/ri/${service.toLowerCase()}/${region.toLowerCase()}/${instanceType.toLowerCase()}/${os.toLowerCase()}`;
1916
+ const cached = this.cache.get(cacheKey);
1917
+ if (cached && Array.isArray(cached.rates)) return cached.rates;
1918
+ assertValidAwsPricingService(service);
1919
+ assertValidAwsRegion(region);
1920
+ const url = `${BULK_PRICING_BASE}/${encodeURIComponent(service)}/current/${encodeURIComponent(region)}/index.json`;
1921
+ logger.debug("Fetching AWS bulk pricing for RI rates", { url });
1922
+ try {
1923
+ const res = await fetchWithRetryAndCircuitBreaker(
1924
+ url,
1925
+ { signal: AbortSignal.timeout(6e4) },
1926
+ {
1927
+ maxRetries: 0,
1928
+ allowedHosts: [AWS_PRICING_HOST]
1929
+ }
1930
+ );
1931
+ if (!res.ok) {
1932
+ logger.debug("AWS RI bulk fetch non-OK", { status: res.status });
1933
+ return null;
1934
+ }
1935
+ const bulk = await res.json();
1936
+ const rates = extractRiRatesFromBulk(bulk, instanceType, os);
1937
+ if (rates.length === 0) return null;
1938
+ const entry = {
1939
+ price_per_unit: 0,
1940
+ unit: "none",
1941
+ rates
1942
+ };
1943
+ this.cache.set(cacheKey, entry, "aws", "ri", region, this.ttlSeconds);
1944
+ return rates;
1945
+ } catch (err) {
1946
+ logger.debug("AWS RI bulk fetch failed, will use static fallback", {
1947
+ instanceType,
1948
+ region,
1949
+ err: err instanceof Error ? err.message : String(err)
1950
+ });
1951
+ return null;
1952
+ }
1953
+ }
1954
+ };
1955
+ function firstHourlyPrice(terms) {
1956
+ if (!terms) return null;
1957
+ for (const term of Object.values(terms)) {
1958
+ for (const dim of Object.values(term.priceDimensions ?? {})) {
1959
+ if ((dim.unit ?? "").toLowerCase().startsWith("hr")) {
1960
+ const price = parseFloat(dim.pricePerUnit?.USD ?? "");
1961
+ if (isFinite(price) && price > 0) return price;
1962
+ }
1963
+ }
1964
+ }
1965
+ return null;
1966
+ }
1967
+ function computeEffectiveHourly(offer, term) {
1968
+ let upfront = 0;
1969
+ let hourly = 0;
1970
+ let sawAnything = false;
1971
+ for (const dim of Object.values(offer.priceDimensions ?? {})) {
1972
+ const unit = (dim.unit ?? "").toLowerCase();
1973
+ const value = parseFloat(dim.pricePerUnit?.USD ?? "");
1974
+ if (!isFinite(value)) continue;
1975
+ if (unit.startsWith("hr")) {
1976
+ hourly = value;
1977
+ sawAnything = true;
1978
+ } else if (unit === "quantity") {
1979
+ upfront = value;
1980
+ sawAnything = true;
1981
+ }
1982
+ }
1983
+ if (!sawAnything) return null;
1984
+ return hourly + upfront / HOURS_IN_TERM[term];
1985
+ }
1986
+ function normalizeTerm(lease) {
1987
+ if (lease === "1yr") return "1yr";
1988
+ if (lease === "3yr") return "3yr";
1989
+ return null;
1990
+ }
1991
+ function normalizePayment(purchase) {
1992
+ switch (purchase) {
1993
+ case "No Upfront":
1994
+ return "no_upfront";
1995
+ case "All Upfront":
1996
+ return "all_upfront";
1997
+ case "Partial Upfront":
1998
+ return "partial_upfront";
1999
+ default:
2000
+ return null;
2001
+ }
2002
+ }
2003
+
2004
+ // src/pricing/azure/azure-normalizer.ts
2005
+ function normalize2(item, spec) {
2006
+ return {
2007
+ provider: "azure",
2008
+ service: spec.service,
2009
+ resource_type: item.skuName ?? item.armSkuName ?? "unknown",
2010
+ region: item.armRegionName ?? item.location ?? "",
2011
+ unit: item.unitOfMeasure ?? spec.defaultUnit,
2012
+ price_per_unit: item.retailPrice ?? item.unitPrice ?? 0,
2013
+ currency: item.currencyCode ?? "USD",
2014
+ tier: spec.includeTier && item.tierMinimumUnits !== void 0 ? String(item.tierMinimumUnits) : void 0,
2015
+ description: item.productName ?? void 0,
2016
+ attributes: {
2017
+ sku_name: item.skuName ?? "",
2018
+ ...spec.extraAttributes?.(item),
2019
+ service_name: item.serviceName ?? spec.defaultServiceName ?? "",
2020
+ product_name: item.productName ?? "",
2021
+ meter_name: item.meterName ?? "",
1622
2022
  pricing_source: "live"
1623
2023
  },
1624
- effective_date: item.effectiveStartDate ?? (/* @__PURE__ */ new Date()).toISOString()
2024
+ effective_date: resolveEffectiveDate(item.effectiveStartDate)
1625
2025
  };
1626
2026
  }
2027
+ function normalizeAzureCompute(item) {
2028
+ return normalize2(item, {
2029
+ service: "virtual-machines",
2030
+ defaultUnit: "1 Hour",
2031
+ includeTier: true,
2032
+ defaultServiceName: "Virtual Machines",
2033
+ extraAttributes: (i) => ({ arm_sku_name: i.armSkuName ?? "" })
2034
+ });
2035
+ }
2036
+ function normalizeAzureDatabase(item) {
2037
+ return normalize2(item, {
2038
+ service: "azure-database",
2039
+ defaultUnit: "1 Hour"
2040
+ });
2041
+ }
2042
+ function normalizeAzureStorage(item) {
2043
+ return normalize2(item, {
2044
+ service: "managed-disks",
2045
+ defaultUnit: "1 GiB/Month"
2046
+ });
2047
+ }
2048
+
2049
+ // src/pricing/azure/arm-regions.ts
2050
+ var ARM_REGION_MAP = {
2051
+ // Americas
2052
+ eastus: "eastus",
2053
+ eastus2: "eastus2",
2054
+ eastus3: "eastus3",
2055
+ southcentralus: "southcentralus",
2056
+ westus: "westus",
2057
+ westus2: "westus2",
2058
+ westus3: "westus3",
2059
+ centralus: "centralus",
2060
+ northcentralus: "northcentralus",
2061
+ westcentralus: "westcentralus",
2062
+ canadacentral: "canadacentral",
2063
+ canadaeast: "canadaeast",
2064
+ brazilsouth: "brazilsouth",
2065
+ brazilsoutheast: "brazilsoutheast",
2066
+ mexicocentral: "mexicocentral",
2067
+ // Europe
2068
+ northeurope: "northeurope",
2069
+ westeurope: "westeurope",
2070
+ uksouth: "uksouth",
2071
+ ukwest: "ukwest",
2072
+ francecentral: "francecentral",
2073
+ francesouth: "francesouth",
2074
+ germanywestcentral: "germanywestcentral",
2075
+ germanynorth: "germanynorth",
2076
+ switzerlandnorth: "switzerlandnorth",
2077
+ switzerlandwest: "switzerlandwest",
2078
+ norwayeast: "norwayeast",
2079
+ norwaywest: "norwaywest",
2080
+ swedencentral: "swedencentral",
2081
+ swedensouth: "swedensouth",
2082
+ polandcentral: "polandcentral",
2083
+ italynorth: "italynorth",
2084
+ spaincentral: "spaincentral",
2085
+ // Asia Pacific
2086
+ eastasia: "eastasia",
2087
+ southeastasia: "southeastasia",
2088
+ japaneast: "japaneast",
2089
+ japanwest: "japanwest",
2090
+ australiaeast: "australiaeast",
2091
+ australiasoutheast: "australiasoutheast",
2092
+ australiacentral: "australiacentral",
2093
+ australiacentral2: "australiacentral2",
2094
+ centralindia: "centralindia",
2095
+ southindia: "southindia",
2096
+ westindia: "westindia",
2097
+ jioindiawest: "jioindiawest",
2098
+ jioindiacentral: "jioindiacentral",
2099
+ koreacentral: "koreacentral",
2100
+ koreasouth: "koreasouth",
2101
+ // Middle East / Africa
2102
+ uaenorth: "uaenorth",
2103
+ uaecentral: "uaecentral",
2104
+ qatarcentral: "qatarcentral",
2105
+ israelcentral: "israelcentral",
2106
+ southafricanorth: "southafricanorth",
2107
+ southafricawest: "southafricawest"
2108
+ };
2109
+ var ALIASES = {
2110
+ // Americas
2111
+ eastus: "eastus",
2112
+ eastus2: "eastus2",
2113
+ eastus3: "eastus3",
2114
+ southcentralus: "southcentralus",
2115
+ westus: "westus",
2116
+ westus2: "westus2",
2117
+ westus3: "westus3",
2118
+ centralus: "centralus",
2119
+ northcentralus: "northcentralus",
2120
+ westcentralus: "westcentralus",
2121
+ canadacentral: "canadacentral",
2122
+ canadaeast: "canadaeast",
2123
+ brazilsouth: "brazilsouth",
2124
+ brazilsoutheast: "brazilsoutheast",
2125
+ mexicocentral: "mexicocentral",
2126
+ // Europe
2127
+ northeurope: "northeurope",
2128
+ westeurope: "westeurope",
2129
+ uksouth: "uksouth",
2130
+ ukwest: "ukwest",
2131
+ francecentral: "francecentral",
2132
+ francesouth: "francesouth",
2133
+ germanywestcentral: "germanywestcentral",
2134
+ germanynorth: "germanynorth",
2135
+ switzerlandnorth: "switzerlandnorth",
2136
+ switzerlandwest: "switzerlandwest",
2137
+ norwayeast: "norwayeast",
2138
+ norwaywest: "norwaywest",
2139
+ swedencentral: "swedencentral",
2140
+ swedensouth: "swedensouth",
2141
+ polandcentral: "polandcentral",
2142
+ italynorth: "italynorth",
2143
+ spaincentral: "spaincentral",
2144
+ // Asia Pacific
2145
+ eastasia: "eastasia",
2146
+ southeastasia: "southeastasia",
2147
+ japaneast: "japaneast",
2148
+ japanwest: "japanwest",
2149
+ australiaeast: "australiaeast",
2150
+ australiasoutheast: "australiasoutheast",
2151
+ australiacentral: "australiacentral",
2152
+ australiacentral2: "australiacentral2",
2153
+ centralindia: "centralindia",
2154
+ southindia: "southindia",
2155
+ westindia: "westindia",
2156
+ jioindiawest: "jioindiawest",
2157
+ jioindiacentral: "jioindiacentral",
2158
+ koreacentral: "koreacentral",
2159
+ koreasouth: "koreasouth",
2160
+ // Middle East / Africa
2161
+ uaenorth: "uaenorth",
2162
+ uaecentral: "uaecentral",
2163
+ qatarcentral: "qatarcentral",
2164
+ israelcentral: "israelcentral",
2165
+ southafricanorth: "southafricanorth",
2166
+ southafricawest: "southafricawest"
2167
+ };
2168
+ function toArmRegionName(region) {
2169
+ if (!region) return region;
2170
+ const key = region.toLowerCase().replace(/[^a-z0-9]/g, "");
2171
+ return ALIASES[key] ?? ARM_REGION_MAP[key] ?? key;
2172
+ }
1627
2173
 
1628
2174
  // src/pricing/azure/fallback-data.ts
1629
2175
  var VM_BASE_PRICES = {
@@ -1655,27 +2201,26 @@ var VM_BASE_PRICES = {
1655
2201
  standard_e32s_v5: 2.016,
1656
2202
  standard_e2ds_v5: 0.144,
1657
2203
  standard_e4ds_v5: 0.288,
1658
- standard_f2s_v2: 0.085,
1659
- standard_f4s_v2: 0.17,
1660
- standard_f8s_v2: 0.34,
1661
- standard_f16s_v2: 0.68,
1662
- standard_f32s_v2: 1.36,
1663
- standard_l8s_v3: 0.624,
1664
- standard_l16s_v3: 1.248,
2204
+ standard_f2s_v2: 0.0846,
2205
+ standard_f4s_v2: 0.169,
2206
+ standard_f8s_v2: 0.338,
2207
+ standard_f16s_v2: 0.677,
2208
+ standard_f32s_v2: 1.353,
2209
+ standard_l8s_v3: 0.696,
2210
+ standard_l16s_v3: 1.392,
1665
2211
  standard_nc4as_t4_v3: 0.526,
1666
2212
  standard_nc8as_t4_v3: 0.752,
1667
2213
  standard_e2ps_v5: 0.101,
1668
2214
  standard_e4ps_v5: 0.202,
1669
2215
  standard_e8ps_v5: 0.403,
1670
- // v6 series
1671
- standard_d2s_v6: 0.096,
1672
- standard_d4s_v6: 0.192,
1673
- standard_d8s_v6: 0.384,
1674
- standard_d16s_v6: 0.768,
1675
- standard_e2s_v6: 0.126,
1676
- standard_e4s_v6: 0.252,
1677
- standard_e8s_v6: 0.504,
1678
- standard_e16s_v6: 1.008
2216
+ standard_d2s_v6: 0.101,
2217
+ standard_d4s_v6: 0.202,
2218
+ standard_d8s_v6: 0.403,
2219
+ standard_d16s_v6: 0.806,
2220
+ standard_e2s_v6: 0.132,
2221
+ standard_e4s_v6: 0.265,
2222
+ standard_e8s_v6: 0.529,
2223
+ standard_e16s_v6: 1.058
1679
2224
  };
1680
2225
  var DISK_BASE_PRICES = {
1681
2226
  premium_lrs: 0.132,
@@ -1710,21 +2255,15 @@ var NAT_PER_GB2 = 0.045;
1710
2255
  var AKS_HOURLY = 0.1;
1711
2256
  var CACHE_TTL2 = 86400;
1712
2257
  var RETAIL_API_BASE = "https://prices.azure.com/api/retail/prices";
1713
- function regionMultiplier2(region) {
1714
- const multipliers = getRegionPriceMultipliers();
1715
- const mult = multipliers.azure[region.toLowerCase()];
1716
- if (mult === void 0) {
1717
- logger.warn("region-multiplier: unknown Azure region, defaulting to 1.0x", { region });
1718
- return 1;
1719
- }
1720
- return mult;
1721
- }
1722
2258
 
1723
2259
  // src/pricing/azure/retail-client.ts
1724
2260
  var AzureRetailClient = class {
1725
2261
  cache;
1726
- constructor(cache) {
2262
+ /** Cache TTL for pricing writes (seconds); from config.cache.ttl_seconds. */
2263
+ ttlSeconds;
2264
+ constructor(cache, ttlSeconds = CACHE_TTL2) {
1727
2265
  this.cache = cache;
2266
+ this.ttlSeconds = ttlSeconds;
1728
2267
  }
1729
2268
  // -------------------------------------------------------------------------
1730
2269
  // Public API
@@ -1734,13 +2273,13 @@ var AzureRetailClient = class {
1734
2273
  const cached = this.cache.get(cacheKey);
1735
2274
  if (cached) return cached;
1736
2275
  try {
1737
- const armRegion = region.toLowerCase().replace(/\s+/g, "");
2276
+ const armRegion = toArmRegionName(region);
1738
2277
  const filter = this.buildODataFilter("Virtual Machines", armRegion, vmSize, true);
1739
2278
  const items = await this.queryPricing(filter);
1740
2279
  const match = this.pickVmItem(items, vmSize, os);
1741
2280
  if (match) {
1742
2281
  const result = normalizeAzureCompute(match);
1743
- this.cache.set(cacheKey, result, "azure", "vm", region, CACHE_TTL2);
2282
+ this.cache.set(cacheKey, result, "azure", "vm", region, this.ttlSeconds);
1744
2283
  return result;
1745
2284
  }
1746
2285
  } catch (err) {
@@ -1757,11 +2296,11 @@ var AzureRetailClient = class {
1757
2296
  const cached = this.cache.get(cacheKey);
1758
2297
  if (cached) return cached;
1759
2298
  try {
1760
- const armRegion = region.toLowerCase().replace(/\s+/g, "");
2299
+ const armRegion = toArmRegionName(region);
1761
2300
  const serviceName = `Azure Database for ${engine}`;
1762
2301
  const result = await this.queryDatabasePrice(tier, armRegion, serviceName);
1763
2302
  if (result) {
1764
- this.cache.set(cacheKey, result, "azure", "db", region, CACHE_TTL2);
2303
+ this.cache.set(cacheKey, result, "azure", "db", region, this.ttlSeconds);
1765
2304
  return result;
1766
2305
  }
1767
2306
  logger.warn("Azure Retail API returned no matching DB pricing", {
@@ -1816,28 +2355,34 @@ var AzureRetailClient = class {
1816
2355
  /**
1817
2356
  * Parse a VM name like "Standard_D2s_v3" into series info.
1818
2357
  * Returns { series: "Dsv3", vcpus: 2 } or null if unparseable.
2358
+ *
2359
+ * Real-world Azure SKUs have a lot of shape variation we have to handle:
2360
+ * Standard_D2s_v5 – 1-letter family, lower-case variant
2361
+ * Standard_M64ms_v2 – multi-digit vCPU count
2362
+ * Standard_DC4s_v3 – 2-letter family (confidential compute)
2363
+ * Standard_E96ias_v5 – longer (3-letter) variant
2364
+ * Standard_NC4as_T4_v3 – mid-name accelerator segment (T4)
2365
+ * Standard_Eb8as_v5 – single-letter "sub-family" prefix (b)
2366
+ *
2367
+ * The regex below accepts:
2368
+ * <FAMILY 1-2 letters><optional sub-family letter><vCPU digits>
2369
+ * <variant letters><optional _SEG segments like _T4>_v<version digits>
1819
2370
  */
1820
2371
  parseVmSeries(vmName) {
1821
- const match = vmName.match(/Standard_([A-Z])(\d+)([a-z]*)_v(\d+)/i);
1822
- if (!match) return null;
1823
- const [, family, vcpuStr, variant, version] = match;
1824
- const vcpus = parseInt(vcpuStr, 10);
1825
- if (isNaN(vcpus) || vcpus === 0) return null;
1826
- const series = `${family}${variant}v${version}`;
1827
- return { series, vcpus };
2372
+ return parseAzureVmSeries(vmName);
1828
2373
  }
1829
2374
  async getStoragePrice(diskType, region) {
1830
2375
  const cacheKey = this.buildCacheKey("disk", region, diskType);
1831
2376
  const cached = this.cache.get(cacheKey);
1832
2377
  if (cached) return cached;
1833
2378
  try {
1834
- const armRegion = region.toLowerCase().replace(/\s+/g, "");
2379
+ const armRegion = toArmRegionName(region);
1835
2380
  const filter = this.buildODataFilter("Storage", armRegion, diskType);
1836
2381
  const items = await this.queryPricing(filter);
1837
2382
  if (items.length > 0) {
1838
2383
  items.sort((a, b) => (a.skuId ?? "").localeCompare(b.skuId ?? ""));
1839
2384
  const result = normalizeAzureStorage(items[0]);
1840
- this.cache.set(cacheKey, result, "azure", "storage", region, CACHE_TTL2);
2385
+ this.cache.set(cacheKey, result, "azure", "storage", region, this.ttlSeconds);
1841
2386
  return result;
1842
2387
  }
1843
2388
  logger.warn("Azure Retail API returned no matching storage pricing", {
@@ -1853,9 +2398,9 @@ var AzureRetailClient = class {
1853
2398
  }
1854
2399
  return this.fallbackStoragePrice(diskType, region, cacheKey);
1855
2400
  }
1856
- async getLoadBalancerPrice(region) {
1857
- const multiplier = regionMultiplier2(region);
1858
- return {
2401
+ getLoadBalancerPrice(region) {
2402
+ const multiplier = regionMultiplier("azure", region);
2403
+ return Promise.resolve({
1859
2404
  provider: "azure",
1860
2405
  service: "load-balancer",
1861
2406
  resource_type: "standard",
@@ -1869,11 +2414,11 @@ var AzureRetailClient = class {
1869
2414
  pricing_model: "fixed_plus_rules"
1870
2415
  },
1871
2416
  effective_date: (/* @__PURE__ */ new Date()).toISOString()
1872
- };
2417
+ });
1873
2418
  }
1874
- async getNatGatewayPrice(region) {
1875
- const multiplier = regionMultiplier2(region);
1876
- return {
2419
+ getNatGatewayPrice(region) {
2420
+ const multiplier = regionMultiplier("azure", region);
2421
+ return Promise.resolve({
1877
2422
  provider: "azure",
1878
2423
  service: "nat-gateway",
1879
2424
  resource_type: "nat-gateway",
@@ -1886,11 +2431,11 @@ var AzureRetailClient = class {
1886
2431
  per_gb_price: String(NAT_PER_GB2 * multiplier)
1887
2432
  },
1888
2433
  effective_date: (/* @__PURE__ */ new Date()).toISOString()
1889
- };
2434
+ });
1890
2435
  }
1891
- async getKubernetesPrice(region) {
1892
- const multiplier = regionMultiplier2(region);
1893
- return {
2436
+ getKubernetesPrice(region) {
2437
+ const multiplier = regionMultiplier("azure", region);
2438
+ return Promise.resolve({
1894
2439
  provider: "azure",
1895
2440
  service: "aks",
1896
2441
  resource_type: "cluster",
@@ -1901,7 +2446,113 @@ var AzureRetailClient = class {
1901
2446
  description: "Azure Kubernetes Service (uptime SLA tier, per cluster/hour)",
1902
2447
  attributes: {},
1903
2448
  effective_date: (/* @__PURE__ */ new Date()).toISOString()
1904
- };
2449
+ });
2450
+ }
2451
+ /**
2452
+ * Fetch the live Spot VM price for a given VM size/region/OS from the Azure
2453
+ * Retail Prices API. Spot VMs are exposed as distinct rows where the
2454
+ * `meterName` contains "Spot" (e.g. "D2s v5 Spot"). Returns `null` when no
2455
+ * spot row is found — callers should fall back to static discount factors.
2456
+ *
2457
+ * Cache key is namespaced under "vm-spot" so spot and on-demand entries
2458
+ * don't collide.
2459
+ */
2460
+ async getSpotPrice(vmSize, region, os = "linux") {
2461
+ const cacheKey = this.buildCacheKey("vm-spot", region, vmSize, os);
2462
+ const cached = this.cache.get(cacheKey);
2463
+ if (cached) return cached;
2464
+ try {
2465
+ const armRegion = toArmRegionName(region);
2466
+ const filter = this.buildODataFilter("Virtual Machines", armRegion, vmSize, true);
2467
+ const items = await this.queryPricing(filter);
2468
+ const isWindows = os.toLowerCase().includes("windows");
2469
+ const spotItems = items.filter((i) => {
2470
+ const meter = (i.meterName ?? "").toLowerCase();
2471
+ const sku = (i.skuName ?? "").toLowerCase();
2472
+ const hasWindows = sku.includes("windows") || meter.includes("windows");
2473
+ const isSpot = meter.includes("spot") || sku.includes("spot");
2474
+ return isSpot && (isWindows && hasWindows || !isWindows && !hasWindows);
2475
+ });
2476
+ if (spotItems.length > 0) {
2477
+ spotItems.sort((a, b) => (a.skuId ?? "").localeCompare(b.skuId ?? ""));
2478
+ const result = normalizeAzureCompute(spotItems[0]);
2479
+ result.attributes.pricing_source = "live";
2480
+ result.attributes.purchase_option = "spot";
2481
+ this.cache.set(cacheKey, result, "azure", "vm-spot", region, this.ttlSeconds);
2482
+ return result;
2483
+ }
2484
+ } catch (err) {
2485
+ logger.warn("Azure Retail API Spot fetch failed", {
2486
+ region,
2487
+ vmSize,
2488
+ err: err instanceof Error ? err.message : String(err)
2489
+ });
2490
+ }
2491
+ return null;
2492
+ }
2493
+ /**
2494
+ * Fetch the live Reservation price for a VM size/region/term from the Azure
2495
+ * Retail Prices API. The API exposes reservations via `priceType eq
2496
+ * 'Reservation'`, with `reservationTerm` of "1 Year" or "3 Years". Returns
2497
+ * `null` when no reservation row matches — callers should fall back to
2498
+ * static discount factors.
2499
+ *
2500
+ * The returned NormalizedPrice uses the reservation's `unitOfMeasure`
2501
+ * (typically "1 Hour" after Azure's per-hour amortisation but sometimes
2502
+ * "1/Year" for upfront payment). The `retailPrice` is the total reservation
2503
+ * price as returned — callers are expected to convert to an hourly rate
2504
+ * themselves using the unit.
2505
+ */
2506
+ async getReservationPrice(vmSize, region, term) {
2507
+ const cacheKey = this.buildCacheKey("vm-ri", region, vmSize, term);
2508
+ const cached = this.cache.get(cacheKey);
2509
+ if (cached) return cached;
2510
+ try {
2511
+ const armRegion = toArmRegionName(region);
2512
+ const termLabel = term === "1yr" ? "1 Year" : "3 Years";
2513
+ const filter = `serviceName eq 'Virtual Machines' and armRegionName eq '${armRegion}' and priceType eq 'Reservation' and armSkuName eq '${vmSize}'`;
2514
+ const items = await this.queryPricing(filter);
2515
+ const matches = items.filter((i) => (i.reservationTerm ?? "") === termLabel);
2516
+ if (matches.length > 0) {
2517
+ matches.sort((a, b) => (a.skuId ?? "").localeCompare(b.skuId ?? ""));
2518
+ const result = normalizeAzureCompute(matches[0]);
2519
+ result.attributes.pricing_source = "live";
2520
+ result.attributes.purchase_option = "reservation";
2521
+ result.attributes.reservation_term = termLabel;
2522
+ this.cache.set(cacheKey, result, "azure", "vm-ri", region, this.ttlSeconds);
2523
+ return result;
2524
+ }
2525
+ } catch (err) {
2526
+ logger.warn("Azure Retail API Reservation fetch failed", {
2527
+ region,
2528
+ vmSize,
2529
+ term,
2530
+ err: err instanceof Error ? err.message : String(err)
2531
+ });
2532
+ }
2533
+ return null;
2534
+ }
2535
+ /**
2536
+ * Convenience helper: given a VM size and region, return the effective
2537
+ * *hourly* reservation rate (amortised). Returns `null` when the live API
2538
+ * has no matching reservation. This wraps `getReservationPrice` and
2539
+ * converts "1/Year" / "1/3 Years" billed units into an hourly equivalent
2540
+ * using a 730h/month * 12 / 36 month assumption.
2541
+ */
2542
+ async getReservationHourlyRate(vmSize, region, term) {
2543
+ const price = await this.getReservationPrice(vmSize, region, term);
2544
+ if (!price) return null;
2545
+ const unit = (price.unit ?? "").toLowerCase();
2546
+ const raw = price.price_per_unit;
2547
+ if (!isFinite(raw) || raw <= 0) return null;
2548
+ if (unit.includes("hour")) {
2549
+ return raw;
2550
+ }
2551
+ if (unit.includes("year")) {
2552
+ const years = term === "1yr" ? 1 : 3;
2553
+ return raw / (8760 * years);
2554
+ }
2555
+ return raw;
1905
2556
  }
1906
2557
  // -------------------------------------------------------------------------
1907
2558
  // Internal helpers – live API
@@ -1927,8 +2578,8 @@ var AzureRetailClient = class {
1927
2578
  }
1928
2579
  return allItems;
1929
2580
  }
1930
- buildODataFilter(service, armRegion, skuName, exactArmSku) {
1931
- let filter = `serviceName eq '${service}' and armRegionName eq '${armRegion}' and priceType eq 'Consumption'`;
2581
+ buildODataFilter(service, armRegion, skuName, exactArmSku, priceType = "Consumption") {
2582
+ let filter = `serviceName eq '${service}' and armRegionName eq '${armRegion}' and priceType eq '${priceType}'`;
1932
2583
  if (skuName) {
1933
2584
  if (exactArmSku) {
1934
2585
  filter += ` and armSkuName eq '${skuName}'`;
@@ -1949,16 +2600,20 @@ var AzureRetailClient = class {
1949
2600
  pickVmItem(items, vmSize, os) {
1950
2601
  if (items.length === 0) return null;
1951
2602
  const sorted = [...items].sort((a, b) => (a.skuId ?? "").localeCompare(b.skuId ?? ""));
1952
- const vmLower = vmSize.toLowerCase();
2603
+ const skuFragment = vmSize.toLowerCase().replace(/^standard_/, "").replace(/_/g, " ");
1953
2604
  const isWindows = os.toLowerCase().includes("windows");
1954
- for (const item of sorted) {
2605
+ const onDemand = sorted.filter((item) => {
2606
+ const meterLower = (item.meterName ?? "").toLowerCase();
2607
+ return !meterLower.includes("low priority") && !meterLower.includes("spot");
2608
+ });
2609
+ for (const item of onDemand) {
1955
2610
  const skuLower = (item.skuName ?? "").toLowerCase();
1956
- const hasWindows = skuLower.includes("windows");
1957
- if (skuLower.includes(vmLower) && (isWindows && hasWindows || !isWindows && !hasWindows)) {
2611
+ const hasWindows = skuLower.includes("windows") || (item.productName ?? "").toLowerCase().includes("windows");
2612
+ if (skuLower.includes(skuFragment) && (isWindows && hasWindows || !isWindows && !hasWindows)) {
1958
2613
  return item;
1959
2614
  }
1960
2615
  }
1961
- return sorted[0];
2616
+ return onDemand[0] ?? sorted[0];
1962
2617
  }
1963
2618
  // -------------------------------------------------------------------------
1964
2619
  // Internal helpers – size interpolation
@@ -1998,7 +2653,7 @@ var AzureRetailClient = class {
1998
2653
  logger.warn("No fallback price found for Azure VM size", { vmSize });
1999
2654
  return null;
2000
2655
  }
2001
- const multiplier = regionMultiplier2(region);
2656
+ const multiplier = regionMultiplier("azure", region);
2002
2657
  const result = {
2003
2658
  provider: "azure",
2004
2659
  service: "virtual-machines",
@@ -2015,7 +2670,7 @@ var AzureRetailClient = class {
2015
2670
  },
2016
2671
  effective_date: (/* @__PURE__ */ new Date()).toISOString()
2017
2672
  };
2018
- this.cache.set(cacheKey, result, "azure", "vm", region, CACHE_TTL2);
2673
+ this.cache.set(cacheKey, result, "azure", "vm", region, this.ttlSeconds);
2019
2674
  return result;
2020
2675
  }
2021
2676
  fallbackDatabasePrice(tier, region, engine, cacheKey) {
@@ -2025,7 +2680,7 @@ var AzureRetailClient = class {
2025
2680
  logger.warn("No fallback price found for Azure DB tier", { tier });
2026
2681
  return null;
2027
2682
  }
2028
- const multiplier = regionMultiplier2(region);
2683
+ const multiplier = regionMultiplier("azure", region);
2029
2684
  const result = {
2030
2685
  provider: "azure",
2031
2686
  service: "azure-database",
@@ -2042,7 +2697,7 @@ var AzureRetailClient = class {
2042
2697
  },
2043
2698
  effective_date: (/* @__PURE__ */ new Date()).toISOString()
2044
2699
  };
2045
- this.cache.set(cacheKey, result, "azure", "db", region, CACHE_TTL2);
2700
+ this.cache.set(cacheKey, result, "azure", "db", region, this.ttlSeconds);
2046
2701
  return result;
2047
2702
  }
2048
2703
  fallbackStoragePrice(diskType, region, cacheKey) {
@@ -2052,7 +2707,7 @@ var AzureRetailClient = class {
2052
2707
  logger.warn("No fallback price found for Azure disk type", { diskType });
2053
2708
  return null;
2054
2709
  }
2055
- const multiplier = regionMultiplier2(region);
2710
+ const multiplier = regionMultiplier("azure", region);
2056
2711
  const result = {
2057
2712
  provider: "azure",
2058
2713
  service: "managed-disks",
@@ -2068,79 +2723,77 @@ var AzureRetailClient = class {
2068
2723
  },
2069
2724
  effective_date: (/* @__PURE__ */ new Date()).toISOString()
2070
2725
  };
2071
- this.cache.set(cacheKey, result, "azure", "storage", region, CACHE_TTL2);
2726
+ this.cache.set(cacheKey, result, "azure", "storage", region, this.ttlSeconds);
2072
2727
  return result;
2073
2728
  }
2074
2729
  };
2730
+ function parseAzureVmSeries(vmName) {
2731
+ const match = vmName.match(
2732
+ /^Standard_([A-Z]{1,2})([a-z]?)(\d+)([a-z]*)((?:_[A-Za-z0-9]+)*)_v(\d+)$/i
2733
+ );
2734
+ if (!match) return null;
2735
+ const [, family, subFamily, vcpuStr, variant, extras, version] = match;
2736
+ const vcpus = parseInt(vcpuStr, 10);
2737
+ if (isNaN(vcpus) || vcpus === 0) return null;
2738
+ const extrasJoined = (extras ?? "").replace(/_/g, "");
2739
+ const series = `${family}${subFamily}${variant}${extrasJoined}v${version}`;
2740
+ return { series, vcpus };
2741
+ }
2075
2742
 
2076
2743
  // src/pricing/gcp/gcp-normalizer.ts
2077
- function normalizeGcpCompute(machineType, hourlyPrice, region) {
2744
+ function normalize3(resourceType, pricePerUnit, region, spec, opts) {
2078
2745
  return {
2079
2746
  provider: "gcp",
2080
- service: "compute-engine",
2081
- resource_type: machineType,
2747
+ service: spec.service,
2748
+ resource_type: resourceType,
2082
2749
  region,
2083
- unit: "h",
2084
- price_per_unit: hourlyPrice,
2750
+ unit: spec.unit,
2751
+ price_per_unit: pricePerUnit,
2085
2752
  currency: "USD",
2086
- description: `GCP Compute Engine ${machineType}`,
2753
+ description: `${spec.descriptionPrefix} ${resourceType}`,
2087
2754
  attributes: {
2088
- machine_type: machineType,
2089
- pricing_source: "bundled"
2755
+ [spec.attrKey]: resourceType,
2756
+ ...opts.skuId !== void 0 ? { sku_id: opts.skuId } : {},
2757
+ pricing_source: opts.source
2090
2758
  },
2091
- effective_date: (/* @__PURE__ */ new Date()).toISOString()
2759
+ effective_date: resolveEffectiveDate(opts.effectiveDate)
2092
2760
  };
2093
2761
  }
2762
+ var COMPUTE_SPEC = {
2763
+ service: "compute-engine",
2764
+ unit: "h",
2765
+ descriptionPrefix: "GCP Compute Engine",
2766
+ attrKey: "machine_type"
2767
+ };
2768
+ var DATABASE_SPEC = {
2769
+ service: "cloud-sql",
2770
+ unit: "h",
2771
+ descriptionPrefix: "GCP Cloud SQL",
2772
+ attrKey: "tier"
2773
+ };
2774
+ var STORAGE_SPEC = {
2775
+ service: "cloud-storage",
2776
+ unit: "GiBy.mo",
2777
+ descriptionPrefix: "GCP Cloud Storage",
2778
+ attrKey: "storage_class"
2779
+ };
2780
+ var DISK_SPEC = {
2781
+ service: "persistent-disk",
2782
+ unit: "GiBy.mo",
2783
+ descriptionPrefix: "GCP Persistent Disk",
2784
+ attrKey: "disk_type"
2785
+ };
2786
+ function normalizeGcpCompute(machineType, hourlyPrice, region) {
2787
+ return normalize3(machineType, hourlyPrice, region, COMPUTE_SPEC, { source: "bundled" });
2788
+ }
2094
2789
  function normalizeGcpDatabase(tier, hourlyPrice, region) {
2095
- return {
2096
- provider: "gcp",
2097
- service: "cloud-sql",
2098
- resource_type: tier,
2099
- region,
2100
- unit: "h",
2101
- price_per_unit: hourlyPrice,
2102
- currency: "USD",
2103
- description: `GCP Cloud SQL ${tier}`,
2104
- attributes: {
2105
- tier,
2106
- pricing_source: "bundled"
2107
- },
2108
- effective_date: (/* @__PURE__ */ new Date()).toISOString()
2109
- };
2790
+ return normalize3(tier, hourlyPrice, region, DATABASE_SPEC, { source: "bundled" });
2110
2791
  }
2111
2792
  function normalizeGcpStorage(storageClass, pricePerGb, region) {
2112
- return {
2113
- provider: "gcp",
2114
- service: "cloud-storage",
2115
- resource_type: storageClass,
2116
- region,
2117
- unit: "GiBy.mo",
2118
- price_per_unit: pricePerGb,
2119
- currency: "USD",
2120
- description: `GCP Cloud Storage ${storageClass}`,
2121
- attributes: {
2122
- storage_class: storageClass,
2123
- pricing_source: "bundled"
2124
- },
2125
- effective_date: (/* @__PURE__ */ new Date()).toISOString()
2126
- };
2793
+ return normalize3(storageClass, pricePerGb, region, STORAGE_SPEC, { source: "bundled" });
2127
2794
  }
2128
2795
  function normalizeGcpDisk(diskType, pricePerGb, region) {
2129
- return {
2130
- provider: "gcp",
2131
- service: "persistent-disk",
2132
- resource_type: diskType,
2133
- region,
2134
- unit: "GiBy.mo",
2135
- price_per_unit: pricePerGb,
2136
- currency: "USD",
2137
- description: `GCP Persistent Disk ${diskType}`,
2138
- attributes: {
2139
- disk_type: diskType,
2140
- pricing_source: "bundled"
2141
- },
2142
- effective_date: (/* @__PURE__ */ new Date()).toISOString()
2143
- };
2796
+ return normalize3(diskType, pricePerGb, region, DISK_SPEC, { source: "bundled" });
2144
2797
  }
2145
2798
 
2146
2799
  // src/pricing/gcp/bundled-loader.ts
@@ -2150,20 +2803,11 @@ var NAT_HOURLY3 = 0.044;
2150
2803
  var NAT_PER_GB3 = 0.045;
2151
2804
  var GKE_STANDARD_HOURLY = 0.1;
2152
2805
  var GKE_AUTOPILOT_VCPU_HOURLY = 0.0445;
2153
- function regionMultiplier3(region) {
2154
- const multipliers = getRegionPriceMultipliers();
2155
- const mult = multipliers.gcp[region.toLowerCase()];
2156
- if (mult === void 0) {
2157
- logger.warn("region-multiplier: unknown GCP region, defaulting to 1.0x", { region });
2158
- return 1;
2159
- }
2160
- return mult;
2161
- }
2162
2806
  var GcpBundledLoader = class {
2163
2807
  // -------------------------------------------------------------------------
2164
2808
  // Public API
2165
2809
  // -------------------------------------------------------------------------
2166
- async getComputePrice(machineType, region) {
2810
+ getComputePrice(machineType, region) {
2167
2811
  try {
2168
2812
  const pricing = getGcpComputePricing();
2169
2813
  const regionPrices = pricing[region];
@@ -2172,7 +2816,7 @@ var GcpBundledLoader = class {
2172
2816
  region,
2173
2817
  machineType
2174
2818
  });
2175
- return null;
2819
+ return Promise.resolve(null);
2176
2820
  }
2177
2821
  const price = regionPrices[machineType];
2178
2822
  if (price === void 0) {
@@ -2180,19 +2824,19 @@ var GcpBundledLoader = class {
2180
2824
  region,
2181
2825
  machineType
2182
2826
  });
2183
- return null;
2827
+ return Promise.resolve(null);
2184
2828
  }
2185
- return normalizeGcpCompute(machineType, price, region);
2829
+ return Promise.resolve(normalizeGcpCompute(machineType, price, region));
2186
2830
  } catch (err) {
2187
2831
  logger.error("GCP compute pricing load error", {
2188
2832
  machineType,
2189
2833
  region,
2190
2834
  err: err instanceof Error ? err.message : String(err)
2191
2835
  });
2192
- return null;
2836
+ return Promise.resolve(null);
2193
2837
  }
2194
2838
  }
2195
- async getDatabasePrice(tier, region) {
2839
+ getDatabasePrice(tier, region) {
2196
2840
  try {
2197
2841
  const pricing = getGcpSqlPricing();
2198
2842
  const regionPrices = pricing[region];
@@ -2201,7 +2845,7 @@ var GcpBundledLoader = class {
2201
2845
  region,
2202
2846
  tier
2203
2847
  });
2204
- return null;
2848
+ return Promise.resolve(null);
2205
2849
  }
2206
2850
  const price = regionPrices[tier];
2207
2851
  if (price === void 0 || tier === "storage_per_gb" || tier === "ha_multiplier") {
@@ -2209,19 +2853,19 @@ var GcpBundledLoader = class {
2209
2853
  region,
2210
2854
  tier
2211
2855
  });
2212
- return null;
2856
+ return Promise.resolve(null);
2213
2857
  }
2214
- return normalizeGcpDatabase(tier, price, region);
2858
+ return Promise.resolve(normalizeGcpDatabase(tier, price, region));
2215
2859
  } catch (err) {
2216
2860
  logger.error("GCP SQL pricing load error", {
2217
2861
  tier,
2218
2862
  region,
2219
2863
  err: err instanceof Error ? err.message : String(err)
2220
2864
  });
2221
- return null;
2865
+ return Promise.resolve(null);
2222
2866
  }
2223
2867
  }
2224
- async getStoragePrice(storageClass, region) {
2868
+ getStoragePrice(storageClass, region) {
2225
2869
  try {
2226
2870
  const pricing = getGcpStoragePricing();
2227
2871
  const regionPrices = pricing[region];
@@ -2230,7 +2874,7 @@ var GcpBundledLoader = class {
2230
2874
  region,
2231
2875
  storageClass
2232
2876
  });
2233
- return null;
2877
+ return Promise.resolve(null);
2234
2878
  }
2235
2879
  const classKey = storageClass.toUpperCase();
2236
2880
  const price = regionPrices[classKey];
@@ -2239,19 +2883,19 @@ var GcpBundledLoader = class {
2239
2883
  region,
2240
2884
  storageClass
2241
2885
  });
2242
- return null;
2886
+ return Promise.resolve(null);
2243
2887
  }
2244
- return normalizeGcpStorage(storageClass.toUpperCase(), price, region);
2888
+ return Promise.resolve(normalizeGcpStorage(storageClass.toUpperCase(), price, region));
2245
2889
  } catch (err) {
2246
2890
  logger.error("GCP Storage pricing load error", {
2247
2891
  storageClass,
2248
2892
  region,
2249
2893
  err: err instanceof Error ? err.message : String(err)
2250
2894
  });
2251
- return null;
2895
+ return Promise.resolve(null);
2252
2896
  }
2253
2897
  }
2254
- async getDiskPrice(diskType, region) {
2898
+ getDiskPrice(diskType, region) {
2255
2899
  try {
2256
2900
  const pricing = getGcpDiskPricing();
2257
2901
  const regionPrices = pricing[region];
@@ -2260,7 +2904,7 @@ var GcpBundledLoader = class {
2260
2904
  region,
2261
2905
  diskType
2262
2906
  });
2263
- return null;
2907
+ return Promise.resolve(null);
2264
2908
  }
2265
2909
  const diskKey = diskType;
2266
2910
  const price = regionPrices[diskKey];
@@ -2269,21 +2913,21 @@ var GcpBundledLoader = class {
2269
2913
  region,
2270
2914
  diskType
2271
2915
  });
2272
- return null;
2916
+ return Promise.resolve(null);
2273
2917
  }
2274
- return normalizeGcpDisk(diskType, price, region);
2918
+ return Promise.resolve(normalizeGcpDisk(diskType, price, region));
2275
2919
  } catch (err) {
2276
2920
  logger.error("GCP Disk pricing load error", {
2277
2921
  diskType,
2278
2922
  region,
2279
2923
  err: err instanceof Error ? err.message : String(err)
2280
2924
  });
2281
- return null;
2925
+ return Promise.resolve(null);
2282
2926
  }
2283
2927
  }
2284
- async getLoadBalancerPrice(region) {
2285
- const multiplier = regionMultiplier3(region);
2286
- return {
2928
+ getLoadBalancerPrice(region) {
2929
+ const multiplier = regionMultiplier("gcp", region);
2930
+ return Promise.resolve({
2287
2931
  provider: "gcp",
2288
2932
  service: "cloud-load-balancing",
2289
2933
  resource_type: "forwarding-rule",
@@ -2296,11 +2940,11 @@ var GcpBundledLoader = class {
2296
2940
  per_gb_price: String(LB_PER_GB * multiplier)
2297
2941
  },
2298
2942
  effective_date: (/* @__PURE__ */ new Date()).toISOString()
2299
- };
2943
+ });
2300
2944
  }
2301
- async getNatGatewayPrice(region) {
2302
- const multiplier = regionMultiplier3(region);
2303
- return {
2945
+ getNatGatewayPrice(region) {
2946
+ const multiplier = regionMultiplier("gcp", region);
2947
+ return Promise.resolve({
2304
2948
  provider: "gcp",
2305
2949
  service: "cloud-nat",
2306
2950
  resource_type: "nat-gateway",
@@ -2313,12 +2957,12 @@ var GcpBundledLoader = class {
2313
2957
  per_gb_price: String(NAT_PER_GB3 * multiplier)
2314
2958
  },
2315
2959
  effective_date: (/* @__PURE__ */ new Date()).toISOString()
2316
- };
2960
+ });
2317
2961
  }
2318
- async getKubernetesPrice(region, mode = "standard") {
2319
- const multiplier = regionMultiplier3(region);
2962
+ getKubernetesPrice(region, mode = "standard") {
2963
+ const multiplier = regionMultiplier("gcp", region);
2320
2964
  const baseHourly = mode === "autopilot" ? GKE_AUTOPILOT_VCPU_HOURLY : GKE_STANDARD_HOURLY;
2321
- return {
2965
+ return Promise.resolve({
2322
2966
  provider: "gcp",
2323
2967
  service: "gke",
2324
2968
  resource_type: "cluster",
@@ -2335,7 +2979,7 @@ var GcpBundledLoader = class {
2335
2979
  }
2336
2980
  },
2337
2981
  effective_date: (/* @__PURE__ */ new Date()).toISOString()
2338
- };
2982
+ });
2339
2983
  }
2340
2984
  };
2341
2985
 
@@ -2344,7 +2988,9 @@ var BILLING_API_BASE = "https://cloudbilling.googleapis.com/v1/services";
2344
2988
  var SERVICE_IDS = {
2345
2989
  COMPUTE: "6F81-5844-456A",
2346
2990
  CLOUD_SQL: "9662-B51E-5089",
2347
- CLOUD_STORAGE: "95FF-2EF5-5EA1"
2991
+ CLOUD_STORAGE: "95FF-2EF5-5EA1",
2992
+ // GKE (Kubernetes Engine) service — used to locate Autopilot pod SKUs.
2993
+ GKE: "CCD8-9BF1-090E"
2348
2994
  };
2349
2995
  var CACHE_TTL3 = 86400;
2350
2996
  function protoToFloat(units, nanos) {
@@ -2372,8 +3018,11 @@ function skuCoversRegion(sku, region) {
2372
3018
  }
2373
3019
  var CloudBillingClient = class {
2374
3020
  cache;
2375
- constructor(cache) {
3021
+ /** Cache TTL for pricing writes (seconds); from config.cache.ttl_seconds. */
3022
+ ttlSeconds;
3023
+ constructor(cache, ttlSeconds = CACHE_TTL3) {
2376
3024
  this.cache = cache;
3025
+ this.ttlSeconds = ttlSeconds;
2377
3026
  }
2378
3027
  // -------------------------------------------------------------------------
2379
3028
  // Public fetch methods
@@ -2391,7 +3040,7 @@ var CloudBillingClient = class {
2391
3040
  const family = this.machineFamily(machineType);
2392
3041
  const result = this.extractComputePrice(skus, machineType, family, region);
2393
3042
  if (result) {
2394
- this.cache.set(cacheKey, result, "gcp", "compute", region, CACHE_TTL3);
3043
+ this.cache.set(cacheKey, result, "gcp", "compute", region, this.ttlSeconds);
2395
3044
  return result;
2396
3045
  }
2397
3046
  } catch (err) {
@@ -2414,7 +3063,7 @@ var CloudBillingClient = class {
2414
3063
  const skus = await this.fetchSkus(SERVICE_IDS.CLOUD_SQL, region);
2415
3064
  const result = this.extractDatabasePrice(skus, tier, region);
2416
3065
  if (result) {
2417
- this.cache.set(cacheKey, result, "gcp", "cloud-sql", region, CACHE_TTL3);
3066
+ this.cache.set(cacheKey, result, "gcp", "cloud-sql", region, this.ttlSeconds);
2418
3067
  return result;
2419
3068
  }
2420
3069
  } catch (err) {
@@ -2438,7 +3087,7 @@ var CloudBillingClient = class {
2438
3087
  const skus = await this.fetchSkus(SERVICE_IDS.CLOUD_STORAGE, region);
2439
3088
  const result = this.extractStoragePrice(skus, storageClass, region);
2440
3089
  if (result) {
2441
- this.cache.set(cacheKey, result, "gcp", "cloud-storage", region, CACHE_TTL3);
3090
+ this.cache.set(cacheKey, result, "gcp", "cloud-storage", region, this.ttlSeconds);
2442
3091
  return result;
2443
3092
  }
2444
3093
  } catch (err) {
@@ -2450,6 +3099,141 @@ var CloudBillingClient = class {
2450
3099
  }
2451
3100
  return null;
2452
3101
  }
3102
+ /**
3103
+ * Fetch Compute Engine GPU (accelerator) SKUs and return the hourly price
3104
+ * for the given accelerator type (e.g. "nvidia-tesla-t4", "nvidia-a100").
3105
+ *
3106
+ * GPUs live under the Compute service (6F81-5844-456A) with
3107
+ * `category.resourceFamily == "Compute"` and
3108
+ * `category.resourceGroup == "GPU"`. Descriptions follow the pattern
3109
+ * "Nvidia Tesla T4 GPU running in Americas".
3110
+ */
3111
+ async fetchGpuSkus(accelerator, region) {
3112
+ const cacheKey = `gcp/gpu/${region}/${accelerator.toLowerCase()}`;
3113
+ const cached = this.cache.get(cacheKey);
3114
+ if (cached) return cached;
3115
+ try {
3116
+ const skus = await this.fetchSkus(SERVICE_IDS.COMPUTE, region);
3117
+ const result = this.extractGpuPrice(skus, accelerator, region);
3118
+ if (result) {
3119
+ this.cache.set(cacheKey, result, "gcp", "compute-gpu", region, this.ttlSeconds);
3120
+ return result;
3121
+ }
3122
+ } catch (err) {
3123
+ logger.warn("GCP Cloud Billing GPU fetch failed", {
3124
+ accelerator,
3125
+ region,
3126
+ err: err instanceof Error ? err.message : String(err)
3127
+ });
3128
+ }
3129
+ return null;
3130
+ }
3131
+ /**
3132
+ * Fetch Committed Use Discount (CUD) rates for the Compute service in a
3133
+ * region. Returns discount fractions (0..1) against OnDemand base rates,
3134
+ * derived by comparing matching Commit1Yr / Commit3Yr SKUs to their
3135
+ * OnDemand counterparts for the same resource group (e.g. "N1Standard").
3136
+ *
3137
+ * If the API call fails or no matching SKUs are found, returns null and
3138
+ * callers should fall back to static rates.
3139
+ */
3140
+ async fetchCudRates(region) {
3141
+ const cacheKey = `gcp/cud/${region}`;
3142
+ const cached = this.cache.get(cacheKey);
3143
+ if (cached) return cached;
3144
+ try {
3145
+ const skus = await this.fetchSkus(SERVICE_IDS.COMPUTE, region);
3146
+ const onDemand = /* @__PURE__ */ new Map();
3147
+ const commit1 = /* @__PURE__ */ new Map();
3148
+ const commit3 = /* @__PURE__ */ new Map();
3149
+ const keyOf = (sku) => {
3150
+ const group = sku.category.resourceGroup ?? "";
3151
+ if (!group) return null;
3152
+ const desc = sku.description.toLowerCase();
3153
+ let flavour;
3154
+ if (desc.includes("core") || desc.includes("vcpu")) {
3155
+ flavour = "core";
3156
+ } else if (desc.includes("ram") || desc.includes("memory")) {
3157
+ flavour = "ram";
3158
+ } else {
3159
+ return null;
3160
+ }
3161
+ return `${group}|${flavour}`;
3162
+ };
3163
+ for (const sku of skus) {
3164
+ if (sku.category.resourceFamily !== "Compute") continue;
3165
+ const price = extractUnitPrice(sku);
3166
+ if (price === null) continue;
3167
+ const key = keyOf(sku);
3168
+ if (!key) continue;
3169
+ const usage = sku.category.usageType;
3170
+ if (usage === "OnDemand") {
3171
+ const existing = onDemand.get(key);
3172
+ if (existing === void 0 || price < existing) onDemand.set(key, price);
3173
+ } else if (usage === "Commit1Yr") {
3174
+ const existing = commit1.get(key);
3175
+ if (existing === void 0 || price < existing) commit1.set(key, price);
3176
+ } else if (usage === "Commit3Yr") {
3177
+ const existing = commit3.get(key);
3178
+ if (existing === void 0 || price < existing) commit3.set(key, price);
3179
+ }
3180
+ }
3181
+ const averageDiscount = (commits) => {
3182
+ const ratios = [];
3183
+ for (const [key, commitPrice] of commits) {
3184
+ const base = onDemand.get(key);
3185
+ if (base === void 0 || base <= 0) continue;
3186
+ const discount = 1 - commitPrice / base;
3187
+ if (discount > 0 && discount < 1) ratios.push(discount);
3188
+ }
3189
+ if (ratios.length === 0) return null;
3190
+ const sum = ratios.reduce((a, b) => a + b, 0);
3191
+ return sum / ratios.length;
3192
+ };
3193
+ const result = {
3194
+ term1yr: averageDiscount(commit1),
3195
+ term3yr: averageDiscount(commit3),
3196
+ sample_count: onDemand.size
3197
+ };
3198
+ if (result.term1yr === null && result.term3yr === null) {
3199
+ return null;
3200
+ }
3201
+ this.cache.set(cacheKey, result, "gcp", "compute-cud", region, this.ttlSeconds);
3202
+ return result;
3203
+ } catch (err) {
3204
+ logger.warn("GCP Cloud Billing CUD fetch failed", {
3205
+ region,
3206
+ err: err instanceof Error ? err.message : String(err)
3207
+ });
3208
+ return null;
3209
+ }
3210
+ }
3211
+ /**
3212
+ * Fetch GKE Autopilot pod resource rates (per-vCPU-hour, per-GB-memory-hour,
3213
+ * per-GB-ephemeral-storage-hour) from the GKE service catalog. Returns a
3214
+ * NormalizedPrice whose `price_per_unit` is the vCPU rate and whose
3215
+ * `attributes` carry the memory/storage rates as strings — callers that
3216
+ * know a pod's resource request can compute an exact per-pod price.
3217
+ */
3218
+ async fetchAutopilotPodRates(region) {
3219
+ const cacheKey = `gcp/gke-autopilot/${region}`;
3220
+ const cached = this.cache.get(cacheKey);
3221
+ if (cached) return cached;
3222
+ try {
3223
+ const skus = await this.fetchSkus(SERVICE_IDS.GKE, region);
3224
+ const result = this.extractAutopilotPodRates(skus, region);
3225
+ if (result) {
3226
+ this.cache.set(cacheKey, result, "gcp", "gke-autopilot", region, this.ttlSeconds);
3227
+ return result;
3228
+ }
3229
+ } catch (err) {
3230
+ logger.warn("GCP Cloud Billing GKE Autopilot fetch failed", {
3231
+ region,
3232
+ err: err instanceof Error ? err.message : String(err)
3233
+ });
3234
+ }
3235
+ return null;
3236
+ }
2453
3237
  // -------------------------------------------------------------------------
2454
3238
  // Internal – API fetch
2455
3239
  // -------------------------------------------------------------------------
@@ -2467,10 +3251,14 @@ var CloudBillingClient = class {
2467
3251
  do {
2468
3252
  const pageUrl = pageToken ? `${url}?pageToken=${pageToken}` : url;
2469
3253
  logger.debug("GCP Cloud Billing API request", { url: pageUrl, serviceId, region });
2470
- const res = await fetch(pageUrl, {
2471
- signal: AbortSignal.timeout(3e4),
2472
- headers: { Accept: "application/json" }
2473
- });
3254
+ const res = await fetchWithRetryAndCircuitBreaker(
3255
+ pageUrl,
3256
+ {
3257
+ signal: AbortSignal.timeout(3e4),
3258
+ headers: { Accept: "application/json" }
3259
+ },
3260
+ { maxRetries: 2 }
3261
+ );
2474
3262
  if (!res.ok) {
2475
3263
  throw new Error(`GCP Billing API HTTP ${res.status} for service ${serviceId}`);
2476
3264
  }
@@ -2599,28 +3387,140 @@ var CloudBillingClient = class {
2599
3387
  if (price === null) return null;
2600
3388
  return {
2601
3389
  provider: "gcp",
2602
- service: "cloud-storage",
2603
- resource_type: classUpper,
3390
+ service: "cloud-storage",
3391
+ resource_type: classUpper,
3392
+ region,
3393
+ unit: "GiBy.mo",
3394
+ price_per_unit: price,
3395
+ currency: "USD",
3396
+ description: `GCP Cloud Storage ${classUpper} (live: ${sku.description})`,
3397
+ attributes: {
3398
+ storage_class: classUpper,
3399
+ sku_id: sku.skuId,
3400
+ pricing_source: "live"
3401
+ },
3402
+ effective_date: sku.pricingInfo?.[0]?.effectiveTime ?? (/* @__PURE__ */ new Date()).toISOString()
3403
+ };
3404
+ }
3405
+ /**
3406
+ * Extract a GPU hourly price for the requested accelerator type.
3407
+ * Matches SKUs with resourceGroup == "GPU" whose description contains the
3408
+ * human-readable model name (e.g. "Tesla T4", "A100 40GB").
3409
+ */
3410
+ extractGpuPrice(skus, accelerator, region) {
3411
+ const lower = accelerator.toLowerCase();
3412
+ const stripped = lower.replace(/^nvidia-?/, "");
3413
+ const tokens = stripped.split(/[-\s]+/).filter((t) => t.length > 0);
3414
+ if (tokens.length === 0) return null;
3415
+ const candidates = skus.filter((s) => {
3416
+ if (s.category.resourceFamily !== "Compute") return false;
3417
+ if (s.category.resourceGroup !== "GPU") return false;
3418
+ if (s.category.usageType !== "OnDemand") return false;
3419
+ const desc = s.description.toLowerCase();
3420
+ return tokens.every((tok) => desc.includes(tok));
3421
+ });
3422
+ if (candidates.length === 0) return null;
3423
+ candidates.sort((a, b) => a.skuId.localeCompare(b.skuId));
3424
+ const sku = candidates[0];
3425
+ const price = extractUnitPrice(sku);
3426
+ if (price === null) return null;
3427
+ return {
3428
+ provider: "gcp",
3429
+ service: "compute-engine",
3430
+ resource_type: accelerator,
3431
+ region,
3432
+ unit: "h",
3433
+ price_per_unit: price,
3434
+ currency: "USD",
3435
+ description: `GCP GPU ${accelerator} (live: ${sku.description})`,
3436
+ attributes: {
3437
+ accelerator,
3438
+ sku_id: sku.skuId,
3439
+ pricing_source: "live"
3440
+ },
3441
+ effective_date: sku.pricingInfo?.[0]?.effectiveTime ?? (/* @__PURE__ */ new Date()).toISOString()
3442
+ };
3443
+ }
3444
+ /**
3445
+ * Extract GKE Autopilot pod resource rates. Autopilot exposes separate
3446
+ * SKUs per resource dimension (vCPU, memory, ephemeral storage) under the
3447
+ * GKE service. We look for SKUs whose description contains "Autopilot"
3448
+ * and the matching dimension keyword.
3449
+ */
3450
+ extractAutopilotPodRates(skus, region) {
3451
+ const autopilotSkus = skus.filter((s) => {
3452
+ const desc = s.description.toLowerCase();
3453
+ return s.category.usageType === "OnDemand" && desc.includes("autopilot") && !desc.includes("spot") && !desc.includes("preemptible");
3454
+ });
3455
+ if (autopilotSkus.length === 0) return null;
3456
+ const find = (keywords) => {
3457
+ const matches = autopilotSkus.filter((s) => {
3458
+ const desc = s.description.toLowerCase();
3459
+ return keywords.every((kw) => desc.includes(kw));
3460
+ });
3461
+ if (matches.length === 0) return void 0;
3462
+ matches.sort((a, b) => a.skuId.localeCompare(b.skuId));
3463
+ return matches[0];
3464
+ };
3465
+ const vcpuSku = find(["cpu"]) ?? find(["core"]) ?? find(["vcpu"]);
3466
+ const memorySku = find(["memory"]) ?? find(["ram"]);
3467
+ const storageSku = find(["ephemeral", "storage"]) ?? find(["storage"]);
3468
+ if (!vcpuSku) return null;
3469
+ const vcpuPrice = extractUnitPrice(vcpuSku);
3470
+ if (vcpuPrice === null) return null;
3471
+ const memoryPrice = memorySku ? extractUnitPrice(memorySku) : null;
3472
+ const storagePrice = storageSku ? extractUnitPrice(storageSku) : null;
3473
+ const attributes = {
3474
+ mode: "autopilot",
3475
+ pricing_model: "per_pod_resource",
3476
+ pricing_source: "live",
3477
+ vcpu_hourly: String(vcpuPrice),
3478
+ sku_id_vcpu: vcpuSku.skuId,
3479
+ pricing_note: "Autopilot charges per pod: vCPU, memory, and ephemeral storage are billed separately. price_per_unit reports the vCPU/hr rate; see attributes for memory/storage rates."
3480
+ };
3481
+ if (memoryPrice !== null && memorySku) {
3482
+ attributes.memory_gb_hourly = String(memoryPrice);
3483
+ attributes.sku_id_memory = memorySku.skuId;
3484
+ }
3485
+ if (storagePrice !== null && storageSku) {
3486
+ attributes.ephemeral_storage_gb_hourly = String(storagePrice);
3487
+ attributes.sku_id_storage = storageSku.skuId;
3488
+ }
3489
+ return {
3490
+ provider: "gcp",
3491
+ service: "gke",
3492
+ resource_type: "autopilot-pod",
2604
3493
  region,
2605
- unit: "GiBy.mo",
2606
- price_per_unit: price,
3494
+ unit: "h",
3495
+ price_per_unit: vcpuPrice,
2607
3496
  currency: "USD",
2608
- description: `GCP Cloud Storage ${classUpper} (live: ${sku.description})`,
2609
- attributes: {
2610
- storage_class: classUpper,
2611
- sku_id: sku.skuId,
2612
- pricing_source: "live"
2613
- },
2614
- effective_date: sku.pricingInfo?.[0]?.effectiveTime ?? (/* @__PURE__ */ new Date()).toISOString()
3497
+ description: `GCP GKE Autopilot pod resources (live: ${vcpuSku.description})`,
3498
+ attributes,
3499
+ effective_date: vcpuSku.pricingInfo?.[0]?.effectiveTime ?? (/* @__PURE__ */ new Date()).toISOString()
2615
3500
  };
2616
3501
  }
2617
3502
  };
2618
3503
 
2619
3504
  // src/pricing/pricing-engine.ts
3505
+ var DEFAULT_CACHE_TTL_SECONDS = 86400;
2620
3506
  var AwsProvider = class {
2621
3507
  loader;
2622
- constructor(cache) {
2623
- this.loader = new AwsBulkLoader(cache);
3508
+ spotClient;
3509
+ reservedClient;
3510
+ constructor(cache, ttlSeconds) {
3511
+ this.loader = new AwsBulkLoader(cache, ttlSeconds);
3512
+ this.spotClient = new AwsSpotClient(cache, ttlSeconds);
3513
+ this.reservedClient = new AwsReservedClient(cache, ttlSeconds);
3514
+ }
3515
+ getSpotFactor(instanceType, region, os) {
3516
+ return this.spotClient.getSpotFactor(
3517
+ instanceType,
3518
+ region,
3519
+ os === "Windows" ? "Windows" : "Linux"
3520
+ );
3521
+ }
3522
+ getReservedRates(_instanceType, _region, _os) {
3523
+ return Promise.resolve(null);
2624
3524
  }
2625
3525
  getComputePrice(instanceType, region, os) {
2626
3526
  return this.loader.getComputePrice(instanceType, region, os);
@@ -2643,8 +3543,8 @@ var AwsProvider = class {
2643
3543
  };
2644
3544
  var AzureProvider = class {
2645
3545
  client;
2646
- constructor(cache) {
2647
- this.client = new AzureRetailClient(cache);
3546
+ constructor(cache, ttlSeconds) {
3547
+ this.client = new AzureRetailClient(cache, ttlSeconds);
2648
3548
  }
2649
3549
  getComputePrice(instanceType, region, os) {
2650
3550
  return this.client.getComputePrice(instanceType, region, os);
@@ -2664,13 +3564,31 @@ var AzureProvider = class {
2664
3564
  getKubernetesPrice(region) {
2665
3565
  return this.client.getKubernetesPrice(region);
2666
3566
  }
3567
+ /**
3568
+ * Azure-specific: fetch live Spot VM pricing from the Retail API. Exposed
3569
+ * on the provider so the compute calculator can prefer live spot rows
3570
+ * over static discount factors. Returns null when the live API has no
3571
+ * matching spot row. Not part of the base `PricingProvider` interface
3572
+ * because AWS/GCP use different spot channels.
3573
+ */
3574
+ getSpotPrice(vmSize, region, os) {
3575
+ return this.client.getSpotPrice(vmSize, region, os);
3576
+ }
3577
+ /**
3578
+ * Azure-specific: fetch the live Reservation VM hourly rate (1yr / 3yr)
3579
+ * from the Retail API. Returns null when no reservation row matches,
3580
+ * signalling callers to fall back to the static DISCOUNT_RATES table.
3581
+ */
3582
+ getReservationHourlyRate(vmSize, region, term) {
3583
+ return this.client.getReservationHourlyRate(vmSize, region, term);
3584
+ }
2667
3585
  };
2668
3586
  var GcpProvider = class {
2669
3587
  loader;
2670
3588
  liveClient;
2671
- constructor(cache) {
3589
+ constructor(cache, ttlSeconds) {
2672
3590
  this.loader = new GcpBundledLoader();
2673
- this.liveClient = new CloudBillingClient(cache);
3591
+ this.liveClient = new CloudBillingClient(cache, ttlSeconds);
2674
3592
  }
2675
3593
  async getComputePrice(instanceType, region, _os) {
2676
3594
  try {
@@ -2724,12 +3642,51 @@ var GcpProvider = class {
2724
3642
  return this.loader.getKubernetesPrice(region, mode === "autopilot" ? "autopilot" : "standard");
2725
3643
  }
2726
3644
  };
3645
+ var SERVICE_ALIAS_LOOKUPS = (() => {
3646
+ const groups = [
3647
+ [
3648
+ ["compute", "ec2", "vm", "instance", "virtual-machines"],
3649
+ (p, resourceType, region, attributes) => p.getComputePrice(resourceType, region, attributes.os)
3650
+ ],
3651
+ [
3652
+ ["database", "rds", "sql", "db", "cloud-sql", "azure-database"],
3653
+ (p, resourceType, region, attributes) => p.getDatabasePrice(resourceType, region, attributes.engine)
3654
+ ],
3655
+ [
3656
+ ["storage", "ebs", "disk", "gcs", "managed-disks", "persistent-disk", "cloud-storage"],
3657
+ (p, resourceType, region, attributes) => {
3658
+ const sizeGb = attributes.size_gb ? parseFloat(attributes.size_gb) : void 0;
3659
+ return p.getStoragePrice(resourceType, region, sizeGb);
3660
+ }
3661
+ ],
3662
+ [
3663
+ ["lb", "load-balancer", "alb", "nlb", "elb", "cloud-load-balancing"],
3664
+ (p, resourceType, region) => p.getLoadBalancerPrice(resourceType, region)
3665
+ ],
3666
+ [
3667
+ ["nat", "nat-gateway", "cloud-nat"],
3668
+ (p, _resourceType, region) => p.getNatGatewayPrice(region)
3669
+ ],
3670
+ [
3671
+ ["k8s", "kubernetes", "eks", "aks", "gke"],
3672
+ (p, _resourceType, region, attributes) => p.getKubernetesPrice(region, attributes.mode)
3673
+ ]
3674
+ ];
3675
+ const map = /* @__PURE__ */ new Map();
3676
+ for (const [aliases, lookup] of groups) {
3677
+ for (const alias of aliases) map.set(alias, lookup);
3678
+ }
3679
+ return map;
3680
+ })();
2727
3681
  var PricingEngine = class {
2728
3682
  providers = /* @__PURE__ */ new Map();
2729
- constructor(cache, _config) {
2730
- this.providers.set("aws", new AwsProvider(cache));
2731
- this.providers.set("azure", new AzureProvider(cache));
2732
- this.providers.set("gcp", new GcpProvider(cache));
3683
+ config;
3684
+ constructor(cache, config) {
3685
+ this.config = config;
3686
+ const ttlSeconds = config?.cache?.ttl_seconds ?? DEFAULT_CACHE_TTL_SECONDS;
3687
+ this.providers.set("aws", new AwsProvider(cache, ttlSeconds));
3688
+ this.providers.set("azure", new AzureProvider(cache, ttlSeconds));
3689
+ this.providers.set("gcp", new GcpProvider(cache, ttlSeconds));
2733
3690
  }
2734
3691
  /**
2735
3692
  * Return the PricingProvider for a specific cloud provider.
@@ -2764,24 +3721,9 @@ var PricingEngine = class {
2764
3721
  resourceType,
2765
3722
  region
2766
3723
  });
2767
- if (svc === "compute" || svc === "ec2" || svc === "vm" || svc === "instance" || svc === "virtual-machines") {
2768
- return p.getComputePrice(resourceType, region, attributes.os);
2769
- }
2770
- if (svc === "database" || svc === "rds" || svc === "sql" || svc === "db" || svc === "cloud-sql" || svc === "azure-database") {
2771
- return p.getDatabasePrice(resourceType, region, attributes.engine);
2772
- }
2773
- if (svc === "storage" || svc === "ebs" || svc === "disk" || svc === "gcs" || svc === "managed-disks" || svc === "persistent-disk" || svc === "cloud-storage") {
2774
- const sizeGb = attributes.size_gb ? parseFloat(attributes.size_gb) : void 0;
2775
- return p.getStoragePrice(resourceType, region, sizeGb);
2776
- }
2777
- if (svc === "lb" || svc === "load-balancer" || svc === "alb" || svc === "nlb" || svc === "elb" || svc === "cloud-load-balancing") {
2778
- return p.getLoadBalancerPrice(resourceType, region);
2779
- }
2780
- if (svc === "nat" || svc === "nat-gateway" || svc === "cloud-nat") {
2781
- return p.getNatGatewayPrice(region);
2782
- }
2783
- if (svc === "k8s" || svc === "kubernetes" || svc === "eks" || svc === "aks" || svc === "gke") {
2784
- return p.getKubernetesPrice(region, attributes.mode);
3724
+ const lookup = SERVICE_ALIAS_LOOKUPS.get(svc);
3725
+ if (lookup) {
3726
+ return lookup(p, resourceType, region, attributes);
2785
3727
  }
2786
3728
  logger.warn("PricingEngine: unrecognised service", { service, provider });
2787
3729
  return null;
@@ -2789,13 +3731,27 @@ var PricingEngine = class {
2789
3731
  };
2790
3732
 
2791
3733
  // src/tools/analyze-terraform.ts
2792
- import { z } from "zod";
3734
+ import { z as z3 } from "zod";
2793
3735
 
2794
3736
  // src/parsers/index.ts
2795
- import { dirname as dirname2 } from "path";
3737
+ import { dirname as dirname2, isAbsolute, normalize as normalize4, sep as sep2 } from "path";
2796
3738
 
2797
3739
  // src/parsers/hcl-parser.ts
2798
3740
  import { parse } from "@cdktf/hcl2json";
3741
+
3742
+ // src/util/sanitize.ts
3743
+ function sanitizeForMessage(input, maxLen = 256) {
3744
+ const s = typeof input === "string" ? input : String(input);
3745
+ const noControl = s.replace(/[\x00-\x1F\x7F]/g, "");
3746
+ const noInvisible = noControl.replace(
3747
+ /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF]/g,
3748
+ ""
3749
+ );
3750
+ if (noInvisible.length <= maxLen) return noInvisible;
3751
+ return `${noInvisible.slice(0, maxLen)}\u2026`;
3752
+ }
3753
+
3754
+ // src/parsers/hcl-parser.ts
2799
3755
  async function parseHclToJson(hclContent, filename = "main.tf") {
2800
3756
  if (!hclContent.trim()) {
2801
3757
  logger.debug("Received empty HCL content, returning empty object", {
@@ -2809,9 +3765,44 @@ async function parseHclToJson(hclContent, filename = "main.tf") {
2809
3765
  return result;
2810
3766
  } catch (err) {
2811
3767
  const message = err instanceof Error ? err.message : String(err);
2812
- logger.error("Failed to parse HCL", { filename, error: message });
2813
- throw new Error(`HCL parse error in "${filename}": ${message}`);
3768
+ const safeFilename = sanitizeForMessage(filename, 256);
3769
+ const safeMessage = sanitizeForMessage(message, 512);
3770
+ logger.error("Failed to parse HCL", { filename: safeFilename, error: safeMessage });
3771
+ throw new Error(`HCL parse error in "${safeFilename}": ${safeMessage}`);
3772
+ }
3773
+ }
3774
+
3775
+ // src/parsers/extractors/helpers.ts
3776
+ function firstBlock(block, key) {
3777
+ const nested = block[key];
3778
+ if (Array.isArray(nested) && nested.length > 0) {
3779
+ return nested[0];
3780
+ }
3781
+ return {};
3782
+ }
3783
+ function coerceToString(v) {
3784
+ if (typeof v === "string") return v;
3785
+ if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint" || typeof v === "symbol" || typeof v === "function" || typeof v === "undefined") {
3786
+ return String(v);
3787
+ }
3788
+ return JSON.stringify(v);
3789
+ }
3790
+ function str(v) {
3791
+ return typeof v === "string" && v ? v : void 0;
3792
+ }
3793
+ function num(v) {
3794
+ if (typeof v === "number") return v;
3795
+ if (typeof v === "string") {
3796
+ const n = Number(v);
3797
+ return isNaN(n) ? void 0 : n;
2814
3798
  }
3799
+ return void 0;
3800
+ }
3801
+ function bool(v) {
3802
+ if (typeof v === "boolean") return v;
3803
+ if (v === "true") return true;
3804
+ if (v === "false") return false;
3805
+ return void 0;
2815
3806
  }
2816
3807
 
2817
3808
  // src/parsers/variable-resolver.ts
@@ -2892,7 +3883,8 @@ function substituteInString(str2, variables) {
2892
3883
  return str2.replace(VAR_INLINE_RE, (_match, varName) => {
2893
3884
  if (varName in variables) {
2894
3885
  const v = variables[varName];
2895
- return v === null ? "" : String(v);
3886
+ if (v === null || v === void 0) return "";
3887
+ return coerceToString(v);
2896
3888
  }
2897
3889
  return _match;
2898
3890
  });
@@ -2915,32 +3907,6 @@ function detectProvider(resourceType) {
2915
3907
  );
2916
3908
  }
2917
3909
 
2918
- // src/parsers/extractors/helpers.ts
2919
- function firstBlock(block, key) {
2920
- const nested = block[key];
2921
- if (Array.isArray(nested) && nested.length > 0) {
2922
- return nested[0];
2923
- }
2924
- return {};
2925
- }
2926
- function str(v) {
2927
- return typeof v === "string" && v ? v : void 0;
2928
- }
2929
- function num(v) {
2930
- if (typeof v === "number") return v;
2931
- if (typeof v === "string") {
2932
- const n = Number(v);
2933
- return isNaN(n) ? void 0 : n;
2934
- }
2935
- return void 0;
2936
- }
2937
- function bool(v) {
2938
- if (typeof v === "boolean") return v;
2939
- if (v === "true") return true;
2940
- if (v === "false") return false;
2941
- return void 0;
2942
- }
2943
-
2944
3910
  // src/parsers/extractors/aws-extractor.ts
2945
3911
  var awsExtractors = {
2946
3912
  aws_instance(block) {
@@ -3526,7 +4492,7 @@ function extractTags(block) {
3526
4492
  const result = {};
3527
4493
  for (const [k, v] of Object.entries(tags)) {
3528
4494
  if (typeof v === "string") result[k] = v;
3529
- else if (v !== null && v !== void 0) result[k] = String(v);
4495
+ else if (v !== null && v !== void 0) result[k] = coerceToString(v);
3530
4496
  }
3531
4497
  return result;
3532
4498
  }
@@ -3638,14 +4604,63 @@ function extractResources(hclJson, variables, sourceFile, defaultRegions = {}, w
3638
4604
 
3639
4605
  // src/parsers/module-resolver.ts
3640
4606
  import { readdirSync, readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
3641
- import { join as join2, resolve } from "path";
4607
+ import { join as join2, resolve as resolve2 } from "path";
4608
+
4609
+ // src/parsers/path-safety.ts
4610
+ import { lstatSync, realpathSync } from "fs";
4611
+ import { resolve, sep } from "path";
4612
+ function resolveWithinBoundary(candidate, basePath, rootBoundary) {
4613
+ const resolved = resolve(basePath, candidate);
4614
+ const boundary = resolve(rootBoundary);
4615
+ if (!isWithin(resolved, boundary)) return null;
4616
+ try {
4617
+ const real = realpathSync(resolved);
4618
+ if (!isWithin(real, boundary)) return null;
4619
+ const stat = lstatSync(resolved);
4620
+ if (stat.isSymbolicLink()) return null;
4621
+ return real;
4622
+ } catch {
4623
+ return resolved;
4624
+ }
4625
+ }
4626
+ function isWithin(child, parent) {
4627
+ if (child === parent) return true;
4628
+ const parentWithSep = parent.endsWith(sep) ? parent : parent + sep;
4629
+ return child.startsWith(parentWithSep);
4630
+ }
4631
+
4632
+ // src/parsers/safe-json.ts
4633
+ var FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
4634
+ function safeJsonParse(text) {
4635
+ let droppedKeys = 0;
4636
+ const parsed = JSON.parse(text, (key, value) => {
4637
+ if (FORBIDDEN_KEYS.has(key)) {
4638
+ droppedKeys++;
4639
+ return void 0;
4640
+ }
4641
+ return value;
4642
+ });
4643
+ if (droppedKeys > 0) {
4644
+ logger.warn("safeJsonParse: dropped prototype-pollution keys from payload", {
4645
+ droppedKeys
4646
+ });
4647
+ }
4648
+ return parsed;
4649
+ }
4650
+
4651
+ // src/parsers/module-resolver.ts
3642
4652
  var MAX_MODULE_DEPTH = 10;
4653
+ var FORBIDDEN_MERGE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
3643
4654
  function readTfFiles(dirPath) {
3644
4655
  if (!existsSync2(dirPath)) return [];
3645
4656
  let entries;
3646
4657
  try {
3647
4658
  entries = readdirSync(dirPath);
3648
- } catch {
4659
+ } catch (err) {
4660
+ logger.warn("Cannot list module directory; skipping", {
4661
+ dirPath: sanitizeForMessage(dirPath),
4662
+ error: sanitizeForMessage(err instanceof Error ? err.message : String(err), 512)
4663
+ });
3649
4664
  return [];
3650
4665
  }
3651
4666
  const files = [];
@@ -3655,7 +4670,11 @@ function readTfFiles(dirPath) {
3655
4670
  try {
3656
4671
  const content = readFileSync2(fullPath, "utf-8");
3657
4672
  files.push({ path: fullPath, content });
3658
- } catch {
4673
+ } catch (err) {
4674
+ logger.warn("Cannot read module file; skipping", {
4675
+ path: sanitizeForMessage(fullPath),
4676
+ error: sanitizeForMessage(err instanceof Error ? err.message : String(err), 512)
4677
+ });
3659
4678
  }
3660
4679
  }
3661
4680
  return files;
@@ -3664,6 +4683,7 @@ function mergeHclJsons(jsons) {
3664
4683
  const merged = {};
3665
4684
  for (const json of jsons) {
3666
4685
  for (const [key, value] of Object.entries(json)) {
4686
+ if (FORBIDDEN_MERGE_KEYS.has(key)) continue;
3667
4687
  if (!(key in merged)) {
3668
4688
  merged[key] = JSON.parse(JSON.stringify(value));
3669
4689
  continue;
@@ -3684,8 +4704,13 @@ function mergeHclJsons(jsons) {
3684
4704
  return merged;
3685
4705
  }
3686
4706
  function mergeObjects(a, b) {
3687
- const result = { ...a };
4707
+ const result = {};
4708
+ for (const [key, aVal] of Object.entries(a)) {
4709
+ if (FORBIDDEN_MERGE_KEYS.has(key)) continue;
4710
+ result[key] = aVal;
4711
+ }
3688
4712
  for (const [key, bVal] of Object.entries(b)) {
4713
+ if (FORBIDDEN_MERGE_KEYS.has(key)) continue;
3689
4714
  const aVal = result[key];
3690
4715
  if (aVal !== null && typeof aVal === "object" && !Array.isArray(aVal) && bVal !== null && typeof bVal === "object" && !Array.isArray(bVal)) {
3691
4716
  result[key] = mergeObjects(aVal, bVal);
@@ -3727,10 +4752,10 @@ function prefixResources(resources, moduleName) {
3727
4752
  id: `module.${moduleName}.${r.id}`
3728
4753
  }));
3729
4754
  }
3730
- async function parseModuleDirectory(dirPath, parentVars, moduleInputs, warnings, depth) {
4755
+ async function parseModuleDirectory(dirPath, parentVars, moduleInputs, warnings, depth, rootBoundary) {
3731
4756
  if (depth >= MAX_MODULE_DEPTH) {
3732
4757
  warnings.push(
3733
- `Module nesting depth limit (${MAX_MODULE_DEPTH}) reached at "${dirPath}". Skipping further expansion.`
4758
+ `Module nesting depth limit (${MAX_MODULE_DEPTH}) reached at "${sanitizeForMessage(dirPath)}". Skipping further expansion.`
3734
4759
  );
3735
4760
  return [];
3736
4761
  }
@@ -3745,10 +4770,12 @@ async function parseModuleDirectory(dirPath, parentVars, moduleInputs, warnings,
3745
4770
  parsedJsons.push({ path: file.path, json });
3746
4771
  } catch (err) {
3747
4772
  const msg = err instanceof Error ? err.message : String(err);
3748
- warnings.push(`Parse error in module file ${file.path}: ${msg}`);
4773
+ const safePath = sanitizeForMessage(file.path);
4774
+ const safeMsg = sanitizeForMessage(msg, 512);
4775
+ warnings.push(`Parse error in module file ${safePath}: ${safeMsg}`);
3749
4776
  logger.warn("Skipping module file due to parse error", {
3750
- path: file.path,
3751
- error: msg
4777
+ path: safePath,
4778
+ error: safeMsg
3752
4779
  });
3753
4780
  }
3754
4781
  }
@@ -3771,86 +4798,117 @@ async function parseModuleDirectory(dirPath, parentVars, moduleInputs, warnings,
3771
4798
  allResources.push(...resources);
3772
4799
  } catch (err) {
3773
4800
  const msg = err instanceof Error ? err.message : String(err);
3774
- warnings.push(`Extraction error in module file ${path}: ${msg}`);
3775
- logger.warn("Resource extraction failed in module", { path, error: msg });
3776
- }
3777
- }
3778
- const nestedResources = await resolveModules(combined, dirPath, variables, warnings, depth + 1);
4801
+ const safePath = sanitizeForMessage(path);
4802
+ const safeMsg = sanitizeForMessage(msg, 512);
4803
+ warnings.push(`Extraction error in module file ${safePath}: ${safeMsg}`);
4804
+ logger.warn("Resource extraction failed in module", { path: safePath, error: safeMsg });
4805
+ }
4806
+ }
4807
+ const nestedResources = await resolveModules(
4808
+ combined,
4809
+ dirPath,
4810
+ variables,
4811
+ warnings,
4812
+ depth + 1,
4813
+ rootBoundary
4814
+ );
3779
4815
  allResources.push(...nestedResources);
3780
4816
  return allResources;
3781
4817
  }
3782
- async function resolveModules(hclJson, basePath, variables, warnings, depth = 0) {
4818
+ async function resolveModules(hclJson, basePath, variables, warnings, depth = 0, rootBoundary) {
3783
4819
  const moduleBlock = hclJson["module"];
3784
4820
  if (!moduleBlock || typeof moduleBlock !== "object" || Array.isArray(moduleBlock)) {
3785
4821
  return [];
3786
4822
  }
4823
+ const boundary = resolve2(rootBoundary ?? process.cwd());
3787
4824
  const allResources = [];
3788
4825
  for (const [moduleName, moduleDeclaration] of Object.entries(
3789
4826
  moduleBlock
3790
4827
  )) {
4828
+ const safeModuleName = sanitizeForMessage(moduleName, 128);
3791
4829
  const source = getSourceAttr(moduleDeclaration);
3792
4830
  if (!source) {
3793
- warnings.push(`Module "${moduleName}" has no source attribute and will be skipped.`);
4831
+ warnings.push(`Module "${safeModuleName}" has no source attribute and will be skipped.`);
3794
4832
  continue;
3795
4833
  }
4834
+ const safeSource = sanitizeForMessage(source, 256);
3796
4835
  const isLocal = source.startsWith("./") || source.startsWith("../");
3797
4836
  const isGit = source.startsWith("git::") || source.includes("github.com") || source.includes("bitbucket.org") || source.startsWith("hg::");
3798
4837
  if (isGit) {
3799
4838
  warnings.push(
3800
- `Module "${moduleName}" uses a git source ("${source}") which is not supported. Skipping.`
4839
+ `Module "${safeModuleName}" uses a git source ("${safeSource}") which is not supported. Skipping.`
3801
4840
  );
3802
- logger.debug("Skipping git module", { moduleName, source });
4841
+ logger.debug("Skipping git module", { moduleName: safeModuleName, source: safeSource });
3803
4842
  continue;
3804
4843
  }
3805
4844
  const rawInputs = extractModuleInputs(moduleDeclaration);
3806
4845
  const moduleInputs = substituteVariables(rawInputs, variables);
3807
4846
  let moduleDir;
3808
4847
  if (isLocal) {
3809
- moduleDir = resolve(basePath, source);
4848
+ moduleDir = resolveWithinBoundary(source, basePath, boundary);
4849
+ if (!moduleDir) {
4850
+ warnings.push(
4851
+ `Module "${safeModuleName}" source "${safeSource}" resolves outside the allowed root and was rejected.`
4852
+ );
4853
+ logger.warn("Rejected local module path escaping boundary", {
4854
+ moduleName: safeModuleName,
4855
+ source: safeSource
4856
+ });
4857
+ continue;
4858
+ }
3810
4859
  } else {
3811
- const terraformModulesDir = join2(basePath, ".terraform", "modules");
3812
- const candidateDirect = join2(terraformModulesDir, moduleName);
3813
- if (existsSync2(candidateDirect)) {
4860
+ const terraformModulesDir = resolve2(basePath, ".terraform", "modules");
4861
+ const candidateDirect = resolveWithinBoundary(
4862
+ moduleName,
4863
+ terraformModulesDir,
4864
+ terraformModulesDir
4865
+ );
4866
+ if (candidateDirect && existsSync2(candidateDirect)) {
3814
4867
  moduleDir = candidateDirect;
3815
4868
  } else {
3816
4869
  const modulesJsonPath = join2(terraformModulesDir, "modules.json");
4870
+ let resolvedFromJson = null;
3817
4871
  if (existsSync2(modulesJsonPath)) {
3818
4872
  try {
3819
- const modulesJson = JSON.parse(readFileSync2(modulesJsonPath, "utf-8"));
4873
+ const rawJson = readFileSync2(modulesJsonPath, "utf-8");
4874
+ const modulesJson = safeJsonParse(rawJson);
3820
4875
  const entry = modulesJson.Modules?.find(
3821
4876
  (m) => m.Key === moduleName || m.Key.startsWith(`${moduleName}.`)
3822
4877
  );
3823
- if (entry?.Dir) {
3824
- moduleDir = resolve(basePath, entry.Dir);
3825
- } else {
3826
- warnings.push(
3827
- `Registry module "${moduleName}" (source: "${source}") not found in .terraform/modules/. Run "terraform init" to download modules before estimating costs.`
3828
- );
3829
- logger.debug("Registry module not initialised", { moduleName, source });
3830
- continue;
4878
+ if (entry?.Dir && typeof entry.Dir === "string") {
4879
+ resolvedFromJson = resolveWithinBoundary(entry.Dir, basePath, boundary);
3831
4880
  }
3832
4881
  } catch {
3833
- warnings.push(
3834
- `Registry module "${moduleName}" (source: "${source}") not found in .terraform/modules/. Run "terraform init" to download modules before estimating costs.`
3835
- );
3836
- continue;
4882
+ resolvedFromJson = null;
3837
4883
  }
4884
+ }
4885
+ if (resolvedFromJson) {
4886
+ moduleDir = resolvedFromJson;
3838
4887
  } else {
3839
4888
  warnings.push(
3840
- `Registry module "${moduleName}" (source: "${source}") not found in .terraform/modules/. Run "terraform init" to download modules before estimating costs.`
4889
+ `Registry module "${safeModuleName}" (source: "${safeSource}") not found in .terraform/modules/. Run "terraform init" to download modules before estimating costs.`
3841
4890
  );
3842
- logger.debug("Registry module not initialised", { moduleName, source });
4891
+ logger.debug("Registry module not initialised", {
4892
+ moduleName: safeModuleName,
4893
+ source: safeSource
4894
+ });
3843
4895
  continue;
3844
4896
  }
3845
4897
  }
3846
4898
  }
3847
- logger.debug("Resolving module", { moduleName, source, moduleDir, depth });
4899
+ logger.debug("Resolving module", {
4900
+ moduleName: safeModuleName,
4901
+ source: safeSource,
4902
+ moduleDir,
4903
+ depth
4904
+ });
3848
4905
  const childResources = await parseModuleDirectory(
3849
4906
  moduleDir,
3850
4907
  variables,
3851
4908
  moduleInputs,
3852
4909
  warnings,
3853
- depth
4910
+ depth,
4911
+ boundary
3854
4912
  );
3855
4913
  allResources.push(...prefixResources(childResources, moduleName));
3856
4914
  }
@@ -3927,7 +4985,7 @@ function extractAttributes(cfnType, props) {
3927
4985
  return {
3928
4986
  instance_type: asString(props.DBInstanceClass),
3929
4987
  engine: asString(props.Engine),
3930
- storage_size_gb: asNumber(props.AllocatedStorage),
4988
+ storage_size_gb: num(props.AllocatedStorage),
3931
4989
  multi_az: asBoolean(props.MultiAZ)
3932
4990
  };
3933
4991
  case "AWS::S3::Bucket":
@@ -3935,8 +4993,8 @@ function extractAttributes(cfnType, props) {
3935
4993
  case "AWS::EBS::Volume":
3936
4994
  return {
3937
4995
  storage_type: asString(props.VolumeType),
3938
- storage_size_gb: asNumber(props.Size),
3939
- iops: asNumber(props.Iops)
4996
+ storage_size_gb: num(props.Size),
4997
+ iops: num(props.Iops)
3940
4998
  };
3941
4999
  case "AWS::ElasticLoadBalancingV2::LoadBalancer":
3942
5000
  return {
@@ -3948,16 +5006,16 @@ function extractAttributes(cfnType, props) {
3948
5006
  return {};
3949
5007
  case "AWS::Lambda::Function":
3950
5008
  return {
3951
- memory_size: asNumber(props.MemorySize),
3952
- timeout: asNumber(props.Timeout)
5009
+ memory_size: num(props.MemorySize),
5010
+ timeout: num(props.Timeout)
3953
5011
  };
3954
5012
  case "AWS::DynamoDB::Table":
3955
5013
  return {
3956
5014
  billing_mode: asString(props.BillingMode) ?? "PROVISIONED",
3957
- read_capacity: asNumber(
5015
+ read_capacity: num(
3958
5016
  props.ProvisionedThroughput?.ReadCapacityUnits
3959
5017
  ),
3960
- write_capacity: asNumber(
5018
+ write_capacity: num(
3961
5019
  props.ProvisionedThroughput?.WriteCapacityUnits
3962
5020
  )
3963
5021
  };
@@ -3972,7 +5030,7 @@ function extractAttributes(cfnType, props) {
3972
5030
  case "AWS::ElastiCache::CacheCluster":
3973
5031
  return {
3974
5032
  instance_type: asString(props.CacheNodeType),
3975
- node_count: asNumber(props.NumCacheNodes),
5033
+ node_count: num(props.NumCacheNodes),
3976
5034
  engine: asString(props.Engine)
3977
5035
  };
3978
5036
  case "AWS::CloudFront::Distribution":
@@ -3987,7 +5045,8 @@ function extractTags2(props) {
3987
5045
  if (Array.isArray(rawTags)) {
3988
5046
  for (const tag of rawTags) {
3989
5047
  if (tag && typeof tag === "object" && "Key" in tag && "Value" in tag) {
3990
- tags[String(tag.Key)] = String(tag.Value);
5048
+ const { Key, Value } = tag;
5049
+ tags[coerceToString(Key)] = coerceToString(Value);
3991
5050
  }
3992
5051
  }
3993
5052
  }
@@ -3996,14 +5055,6 @@ function extractTags2(props) {
3996
5055
  function asString(value) {
3997
5056
  return typeof value === "string" ? value : void 0;
3998
5057
  }
3999
- function asNumber(value) {
4000
- if (typeof value === "number") return value;
4001
- if (typeof value === "string") {
4002
- const n = Number(value);
4003
- return Number.isNaN(n) ? void 0 : n;
4004
- }
4005
- return void 0;
4006
- }
4007
5058
  function asBoolean(value) {
4008
5059
  if (typeof value === "boolean") return value;
4009
5060
  if (value === "true") return true;
@@ -4023,11 +5074,11 @@ var CloudFormationParser = class {
4023
5074
  return false;
4024
5075
  });
4025
5076
  }
4026
- async parse(files, options) {
5077
+ parse(files, options) {
4027
5078
  const warnings = [];
4028
5079
  const file = files.find((f) => this.extensions.some((ext) => f.path.endsWith(ext)));
4029
5080
  if (!file) {
4030
- return emptyInventory(warnings);
5081
+ return Promise.resolve(emptyInventory(warnings));
4031
5082
  }
4032
5083
  let template;
4033
5084
  try {
@@ -4036,11 +5087,11 @@ var CloudFormationParser = class {
4036
5087
  const msg = err instanceof Error ? err.message : String(err);
4037
5088
  warnings.push(`Failed to parse CloudFormation template ${file.path}: ${msg}`);
4038
5089
  logger.warn("CloudFormation parse error", { path: file.path, error: msg });
4039
- return emptyInventory(warnings);
5090
+ return Promise.resolve(emptyInventory(warnings));
4040
5091
  }
4041
5092
  if (!template || typeof template !== "object" || !template.Resources) {
4042
5093
  warnings.push(`Invalid CloudFormation template: missing Resources section in ${file.path}`);
4043
- return emptyInventory(warnings);
5094
+ return Promise.resolve(emptyInventory(warnings));
4044
5095
  }
4045
5096
  const resolvedParams = resolveParameters(
4046
5097
  template.Parameters ?? {},
@@ -4073,7 +5124,7 @@ var CloudFormationParser = class {
4073
5124
  region,
4074
5125
  warningCount: warnings.length
4075
5126
  });
4076
- return inventory;
5127
+ return Promise.resolve(inventory);
4077
5128
  }
4078
5129
  };
4079
5130
  function emptyInventory(warnings) {
@@ -4179,14 +5230,14 @@ function extractAttributes2(armTypeLower, props, resource, parameters) {
4179
5230
  const diskProps = props;
4180
5231
  return {
4181
5232
  sku: resource.sku?.name,
4182
- storage_size_gb: asNumber2(diskProps.diskSizeGB)
5233
+ storage_size_gb: num(diskProps.diskSizeGB)
4183
5234
  };
4184
5235
  }
4185
5236
  case "microsoft.dbforpostgresql/flexibleservers": {
4186
5237
  const storage = props.storage;
4187
5238
  return {
4188
5239
  sku: resource.sku?.name,
4189
- storage_size_gb: asNumber2(storage?.storageSizeGB)
5240
+ storage_size_gb: num(storage?.storageSizeGB)
4190
5241
  };
4191
5242
  }
4192
5243
  case "microsoft.dbformysql/flexibleservers":
@@ -4210,7 +5261,7 @@ function extractAttributes2(armTypeLower, props, resource, parameters) {
4210
5261
  const pools = props.agentPoolProfiles;
4211
5262
  const firstPool = pools?.[0];
4212
5263
  return {
4213
- node_count: asNumber2(firstPool?.count),
5264
+ node_count: num(firstPool?.count),
4214
5265
  vm_size: typeof firstPool?.vmSize === "string" ? firstPool.vmSize : void 0
4215
5266
  };
4216
5267
  }
@@ -4244,14 +5295,6 @@ function extractAttributes2(armTypeLower, props, resource, parameters) {
4244
5295
  return {};
4245
5296
  }
4246
5297
  }
4247
- function asNumber2(value) {
4248
- if (typeof value === "number") return value;
4249
- if (typeof value === "string") {
4250
- const n = Number(value);
4251
- return Number.isNaN(n) ? void 0 : n;
4252
- }
4253
- return void 0;
4254
- }
4255
5298
 
4256
5299
  // src/parsers/bicep/arm-parser.ts
4257
5300
  var ArmParser = class {
@@ -4263,11 +5306,11 @@ var ArmParser = class {
4263
5306
  return f.content.includes("schema.management.azure.com") || f.content.includes("Microsoft.") && f.content.includes("apiVersion");
4264
5307
  });
4265
5308
  }
4266
- async parse(files, options) {
5309
+ parse(files, options) {
4267
5310
  const warnings = [];
4268
5311
  const file = files.find((f) => f.path.endsWith(".json"));
4269
5312
  if (!file) {
4270
- return emptyInventory2(warnings);
5313
+ return Promise.resolve(emptyInventory2(warnings));
4271
5314
  }
4272
5315
  let template;
4273
5316
  try {
@@ -4276,11 +5319,11 @@ var ArmParser = class {
4276
5319
  const msg = err instanceof Error ? err.message : String(err);
4277
5320
  warnings.push(`Failed to parse ARM template ${file.path}: ${msg}`);
4278
5321
  logger.warn("ARM template parse error", { path: file.path, error: msg });
4279
- return emptyInventory2(warnings);
5322
+ return Promise.resolve(emptyInventory2(warnings));
4280
5323
  }
4281
5324
  if (!template || typeof template !== "object" || !Array.isArray(template.resources)) {
4282
5325
  warnings.push(`Invalid ARM template: missing resources array in ${file.path}`);
4283
- return emptyInventory2(warnings);
5326
+ return Promise.resolve(emptyInventory2(warnings));
4284
5327
  }
4285
5328
  const resolvedParams = resolveParameters2(
4286
5329
  template.parameters ?? {},
@@ -4313,7 +5356,7 @@ var ArmParser = class {
4313
5356
  region,
4314
5357
  warningCount: warnings.length
4315
5358
  });
4316
- return inventory;
5359
+ return Promise.resolve(inventory);
4317
5360
  }
4318
5361
  };
4319
5362
  function emptyInventory2(warnings) {
@@ -4463,7 +5506,7 @@ function extractAttributes3(pulumiType, props) {
4463
5506
  return {
4464
5507
  instance_type: asString2(props.instanceClass),
4465
5508
  engine: asString2(props.engine),
4466
- storage_size_gb: asNumber3(props.allocatedStorage),
5509
+ storage_size_gb: num(props.allocatedStorage),
4467
5510
  multi_az: asBoolean2(props.multiAz)
4468
5511
  };
4469
5512
  case "aws:s3/bucket:Bucket":
@@ -4471,8 +5514,8 @@ function extractAttributes3(pulumiType, props) {
4471
5514
  case "aws:ebs/volume:Volume":
4472
5515
  return {
4473
5516
  storage_type: asString2(props.type),
4474
- storage_size_gb: asNumber3(props.size),
4475
- iops: asNumber3(props.iops)
5517
+ storage_size_gb: num(props.size),
5518
+ iops: num(props.iops)
4476
5519
  };
4477
5520
  case "aws:lb/loadBalancer:LoadBalancer":
4478
5521
  return {
@@ -4484,14 +5527,14 @@ function extractAttributes3(pulumiType, props) {
4484
5527
  return {};
4485
5528
  case "aws:lambda/function:Function":
4486
5529
  return {
4487
- memory_size: asNumber3(props.memorySize),
4488
- timeout: asNumber3(props.timeout)
5530
+ memory_size: num(props.memorySize),
5531
+ timeout: num(props.timeout)
4489
5532
  };
4490
5533
  case "aws:dynamodb/table:Table":
4491
5534
  return {
4492
5535
  billing_mode: asString2(props.billingMode) ?? "PROVISIONED",
4493
- read_capacity: asNumber3(props.readCapacity),
4494
- write_capacity: asNumber3(props.writeCapacity)
5536
+ read_capacity: num(props.readCapacity),
5537
+ write_capacity: num(props.writeCapacity)
4495
5538
  };
4496
5539
  case "aws:route53/zone:Zone":
4497
5540
  return {};
@@ -4537,12 +5580,12 @@ function extractAttributes3(pulumiType, props) {
4537
5580
  };
4538
5581
  case "gcp:container:Cluster":
4539
5582
  return {
4540
- node_count: asNumber3(props.initialNodeCount)
5583
+ node_count: num(props.initialNodeCount)
4541
5584
  };
4542
5585
  case "gcp:cloudfunctions:Function":
4543
5586
  return {
4544
- memory_size: asNumber3(props.availableMemoryMb),
4545
- timeout: asNumber3(props.timeout)
5587
+ memory_size: num(props.availableMemoryMb),
5588
+ timeout: num(props.timeout)
4546
5589
  };
4547
5590
  case "gcp:dns:ManagedZone":
4548
5591
  return {};
@@ -4565,14 +5608,6 @@ function extractTags3(props) {
4565
5608
  function asString2(value) {
4566
5609
  return typeof value === "string" ? value : void 0;
4567
5610
  }
4568
- function asNumber3(value) {
4569
- if (typeof value === "number") return value;
4570
- if (typeof value === "string") {
4571
- const n = Number(value);
4572
- return Number.isNaN(n) ? void 0 : n;
4573
- }
4574
- return void 0;
4575
- }
4576
5611
  function asBoolean2(value) {
4577
5612
  if (typeof value === "boolean") return value;
4578
5613
  if (value === "true") return true;
@@ -4595,7 +5630,7 @@ var PulumiParser = class {
4595
5630
  }
4596
5631
  });
4597
5632
  }
4598
- async parse(files, _options) {
5633
+ parse(files, _options) {
4599
5634
  const warnings = [];
4600
5635
  const file = files.find((f) => {
4601
5636
  try {
@@ -4607,7 +5642,7 @@ var PulumiParser = class {
4607
5642
  }
4608
5643
  });
4609
5644
  if (!file) {
4610
- return emptyInventory3(warnings);
5645
+ return Promise.resolve(emptyInventory3(warnings));
4611
5646
  }
4612
5647
  let stackExport;
4613
5648
  try {
@@ -4616,11 +5651,11 @@ var PulumiParser = class {
4616
5651
  const msg = err instanceof Error ? err.message : String(err);
4617
5652
  warnings.push(`Failed to parse Pulumi stack export ${file.path}: ${msg}`);
4618
5653
  logger.warn("Pulumi parse error", { path: file.path, error: msg });
4619
- return emptyInventory3(warnings);
5654
+ return Promise.resolve(emptyInventory3(warnings));
4620
5655
  }
4621
5656
  if (!stackExport?.deployment?.resources || !Array.isArray(stackExport.deployment.resources)) {
4622
5657
  warnings.push(`Invalid Pulumi stack export: missing deployment.resources in ${file.path}`);
4623
- return emptyInventory3(warnings);
5658
+ return Promise.resolve(emptyInventory3(warnings));
4624
5659
  }
4625
5660
  const resources = [];
4626
5661
  for (const pulumiRes of stackExport.deployment.resources) {
@@ -4654,7 +5689,7 @@ var PulumiParser = class {
4654
5689
  region: dominantRegion,
4655
5690
  warningCount: warnings.length
4656
5691
  });
4657
- return inventory;
5692
+ return Promise.resolve(inventory);
4658
5693
  }
4659
5694
  };
4660
5695
  function emptyInventory3(warnings) {
@@ -4751,7 +5786,7 @@ async function parseTerraform(files, tfvarsContent, basePath, resolveModulesEnab
4751
5786
  parse_warnings: warnings
4752
5787
  };
4753
5788
  }
4754
- const combined = mergeHclJsons2(parsedJsons.map((p) => p.json));
5789
+ const combined = mergeHclJsons(parsedJsons.map((p) => p.json));
4755
5790
  const variables = resolveVariables(combined, tfvarsContent);
4756
5791
  const providers = ["aws", "azure", "gcp"];
4757
5792
  const defaultRegions = {};
@@ -4770,7 +5805,7 @@ async function parseTerraform(files, tfvarsContent, basePath, resolveModulesEnab
4770
5805
  }
4771
5806
  }
4772
5807
  if (resolveModulesEnabled) {
4773
- const moduleBasePath = basePath ?? (parsedJsons.length > 0 ? dirname2(parsedJsons[0].path) : process.cwd());
5808
+ const moduleBasePath = basePath ?? (parsedJsons.length > 0 ? deriveModuleBasePath(parsedJsons[0].path) : process.cwd());
4774
5809
  try {
4775
5810
  const moduleResources = await resolveModules(combined, moduleBasePath, variables, warnings);
4776
5811
  allResources.push(...moduleResources);
@@ -4803,46 +5838,19 @@ async function parseTerraform(files, tfvarsContent, basePath, resolveModulesEnab
4803
5838
  });
4804
5839
  return inventory;
4805
5840
  }
4806
- function mergeHclJsons2(jsons) {
4807
- const merged = {};
4808
- for (const json of jsons) {
4809
- for (const [key, value] of Object.entries(json)) {
4810
- if (!(key in merged)) {
4811
- merged[key] = deepClone(value);
4812
- continue;
4813
- }
4814
- const existing = merged[key];
4815
- if (value !== null && typeof value === "object" && !Array.isArray(value) && existing !== null && typeof existing === "object" && !Array.isArray(existing)) {
4816
- merged[key] = mergeObjects2(
4817
- existing,
4818
- value
4819
- );
4820
- } else if (Array.isArray(existing) && Array.isArray(value)) {
4821
- merged[key] = [...existing, ...value];
4822
- } else {
4823
- merged[key] = deepClone(value);
4824
- }
4825
- }
5841
+ function deriveModuleBasePath(filePath) {
5842
+ const normalized = normalize4(filePath);
5843
+ const isWindowsDrive = /^[A-Za-z]:/.test(normalized);
5844
+ const isWindowsUnc = normalized.startsWith("\\\\") || normalized.startsWith("//");
5845
+ if (isAbsolute(normalized) || isWindowsDrive || isWindowsUnc) {
5846
+ return process.cwd();
4826
5847
  }
4827
- return merged;
4828
- }
4829
- function mergeObjects2(a, b) {
4830
- const result = { ...a };
4831
- for (const [key, bVal] of Object.entries(b)) {
4832
- const aVal = result[key];
4833
- if (aVal !== null && typeof aVal === "object" && !Array.isArray(aVal) && bVal !== null && typeof bVal === "object" && !Array.isArray(bVal)) {
4834
- result[key] = mergeObjects2(aVal, bVal);
4835
- } else if (Array.isArray(aVal) && Array.isArray(bVal)) {
4836
- result[key] = [...aVal, ...bVal];
4837
- } else {
4838
- result[key] = deepClone(bVal);
4839
- }
5848
+ const dir = dirname2(normalized);
5849
+ const escapesUpward = dir === ".." || dir.startsWith(`..${sep2}`) || dir.startsWith("../");
5850
+ if (escapesUpward) {
5851
+ return process.cwd();
4840
5852
  }
4841
- return result;
4842
- }
4843
- function deepClone(value) {
4844
- if (value === null || typeof value !== "object") return value;
4845
- return structuredClone(value);
5853
+ return dir;
4846
5854
  }
4847
5855
 
4848
5856
  // src/parsers/dependency-graph.ts
@@ -4973,16 +5981,54 @@ function generateMermaidDiagram(graph) {
4973
5981
  return lines.join("\n");
4974
5982
  }
4975
5983
 
5984
+ // src/schemas/fragments.ts
5985
+ import { z as z2 } from "zod";
5986
+
5987
+ // src/schemas/bounded.ts
5988
+ import { z } from "zod";
5989
+ var MAX_FILE_PATH_LEN = 1024;
5990
+ var MAX_FILE_CONTENT_BYTES = 5 * 1024 * 1024;
5991
+ var MAX_TFVARS_BYTES = 1 * 1024 * 1024;
5992
+ var MAX_PLAN_JSON_BYTES = 20 * 1024 * 1024;
5993
+ var MAX_STATE_JSON_BYTES = 20 * 1024 * 1024;
5994
+ var MAX_SHORT_STRING_LEN = 256;
5995
+ var MAX_TOTAL_FILE_BYTES = 50 * 1024 * 1024;
5996
+ var filePathSchema = z.string().max(MAX_FILE_PATH_LEN, `File path exceeds ${MAX_FILE_PATH_LEN} characters`);
5997
+ var fileContentSchema = z.string().max(MAX_FILE_CONTENT_BYTES, `File content exceeds ${MAX_FILE_CONTENT_BYTES} bytes`);
5998
+ var tfvarsSchema = z.string().max(MAX_TFVARS_BYTES, `tfvars content exceeds ${MAX_TFVARS_BYTES} bytes`);
5999
+ var planJsonSchema = z.string().max(MAX_PLAN_JSON_BYTES, `plan JSON exceeds ${MAX_PLAN_JSON_BYTES} bytes`);
6000
+ var stateJsonSchema = z.string().max(MAX_STATE_JSON_BYTES, `state JSON exceeds ${MAX_STATE_JSON_BYTES} bytes`);
6001
+ var shortStringSchema = z.string().max(MAX_SHORT_STRING_LEN, `value exceeds ${MAX_SHORT_STRING_LEN} characters`);
6002
+ function assertTotalFileBytesWithin(files, ctx) {
6003
+ let total = 0;
6004
+ for (const f of files) {
6005
+ total += f.content.length;
6006
+ if (total > MAX_TOTAL_FILE_BYTES) {
6007
+ ctx.addIssue({
6008
+ code: z.ZodIssueCode.custom,
6009
+ message: `aggregate file content exceeds ${MAX_TOTAL_FILE_BYTES} bytes`,
6010
+ path: ["files"]
6011
+ });
6012
+ return;
6013
+ }
6014
+ }
6015
+ }
6016
+
6017
+ // src/schemas/fragments.ts
6018
+ var providerEnum = z2.enum(["aws", "azure", "gcp"]);
6019
+ var iacFileEntrySchema = z2.object({
6020
+ path: filePathSchema.describe("File path"),
6021
+ content: fileContentSchema.describe("File content")
6022
+ });
6023
+ var iacFilesSchema = z2.array(iacFileEntrySchema).max(2e3, "files array exceeds 2000 entries").superRefine(assertTotalFileBytesWithin);
6024
+ var tfvarsField = tfvarsSchema.optional().describe("Contents of terraform.tfvars file");
6025
+ var currencyField = z2.enum(SUPPORTED_CURRENCIES).optional().default("USD").describe("Output currency for cost estimates. Defaults to USD.");
6026
+
4976
6027
  // src/tools/analyze-terraform.ts
4977
- var analyzeTerraformSchema = z.object({
4978
- files: z.array(
4979
- z.object({
4980
- path: z.string().describe("File path"),
4981
- content: z.string().describe("File content (HCL)")
4982
- })
4983
- ).describe("Terraform files to analyze"),
4984
- tfvars: z.string().optional().describe("Contents of terraform.tfvars file"),
4985
- include_dependencies: z.boolean().optional().default(false).describe(
6028
+ var analyzeTerraformSchema = z3.object({
6029
+ files: iacFilesSchema.describe("Terraform files to analyze"),
6030
+ tfvars: tfvarsField,
6031
+ include_dependencies: z3.boolean().optional().default(false).describe(
4986
6032
  "When true, parse resource references and depends_on blocks to build a dependency graph and Mermaid diagram"
4987
6033
  )
4988
6034
  });
@@ -5034,7 +6080,7 @@ async function analyzeTerraform(params) {
5034
6080
  }
5035
6081
 
5036
6082
  // src/tools/estimate-cost.ts
5037
- import { z as z2 } from "zod";
6083
+ import { z as z4 } from "zod";
5038
6084
 
5039
6085
  // src/mapping/region-mapper.ts
5040
6086
  var DEFAULT_REGIONS = {
@@ -5063,6 +6109,27 @@ function mapRegion(region, sourceProvider, targetProvider) {
5063
6109
  return mapped;
5064
6110
  }
5065
6111
 
6112
+ // src/calculator/estimate-factory.ts
6113
+ function round2(n) {
6114
+ return Math.round(n * 100) / 100;
6115
+ }
6116
+ function makeCostEstimate(input) {
6117
+ return {
6118
+ resource_id: input.resource.id,
6119
+ resource_type: input.resource.type,
6120
+ resource_name: input.resource.name,
6121
+ provider: input.provider,
6122
+ region: input.region,
6123
+ monthly_cost: round2(input.monthlyCost),
6124
+ yearly_cost: round2(input.monthlyCost * 12),
6125
+ currency: input.currency ?? "USD",
6126
+ breakdown: input.breakdown,
6127
+ confidence: input.confidence,
6128
+ notes: input.notes,
6129
+ pricing_source: input.pricingSource
6130
+ };
6131
+ }
6132
+
5066
6133
  // src/mapping/instance-mapper.ts
5067
6134
  function directionKey(source, target) {
5068
6135
  if (source === target) return null;
@@ -5244,7 +6311,7 @@ async function calculateComputeCost(resource, targetProvider, targetRegion, pric
5244
6311
  `No instance mapping found for ${sourceInstanceType} (${resource.provider} -> ${targetProvider})`
5245
6312
  );
5246
6313
  } else {
5247
- const { getInstanceMap: getInstanceMap2 } = await import("./loader-VXYJYDIH.js");
6314
+ const { getInstanceMap: getInstanceMap2 } = await import("./loader-CMBV7346.js");
5248
6315
  const im = getInstanceMap2();
5249
6316
  const dirKey = `${resource.provider}_to_${targetProvider}`;
5250
6317
  const directMap = im[dirKey];
@@ -5258,11 +6325,7 @@ async function calculateComputeCost(resource, targetProvider, targetRegion, pric
5258
6325
  }
5259
6326
  }
5260
6327
  const effectiveInstance = mappedInstance ?? sourceInstanceType;
5261
- const computePrice = effectiveInstance ? await pricingEngine.getProvider(targetProvider).getComputePrice(
5262
- effectiveInstance,
5263
- targetRegion,
5264
- resource.attributes.os
5265
- ) : null;
6328
+ const computePrice = effectiveInstance ? await pricingEngine.getProvider(targetProvider).getComputePrice(effectiveInstance, targetRegion, resource.attributes.os) : null;
5266
6329
  let computeMonthlyCost = 0;
5267
6330
  let pricingSource = "fallback";
5268
6331
  if (computePrice) {
@@ -5282,13 +6345,69 @@ async function calculateComputeCost(resource, targetProvider, targetRegion, pric
5282
6345
  }
5283
6346
  let hourlyPrice = computePrice.price_per_unit;
5284
6347
  if (pricingConfig.pricing_model === "spot") {
5285
- const family = classifyInstanceFamily(effectiveInstance, targetProvider);
5286
- const providerFactors = SPOT_DISCOUNT_FACTORS[targetProvider] ?? SPOT_DISCOUNT_FACTORS.aws;
5287
- const factor = providerFactors[family] ?? providerFactors.general ?? 0.35;
5288
- const savingsPct = Math.round((1 - factor) * 100);
5289
- hourlyPrice = hourlyPrice * factor;
5290
- pricingSource = "spot-estimate";
5291
- notes.push(`Spot pricing applied (${savingsPct}% discount from on-demand)`);
6348
+ let liveSpotApplied = false;
6349
+ if (targetProvider === "azure") {
6350
+ try {
6351
+ const azureProvider = pricingEngine.getProvider("azure");
6352
+ if (typeof azureProvider.getSpotPrice === "function") {
6353
+ const liveSpot = await azureProvider.getSpotPrice(
6354
+ effectiveInstance,
6355
+ targetRegion,
6356
+ resource.attributes.os
6357
+ );
6358
+ if (liveSpot && liveSpot.price_per_unit > 0 && hourlyPrice > 0) {
6359
+ const savingsPct = Math.max(
6360
+ 0,
6361
+ Math.round((1 - liveSpot.price_per_unit / hourlyPrice) * 100)
6362
+ );
6363
+ hourlyPrice = liveSpot.price_per_unit;
6364
+ pricingSource = "live";
6365
+ liveSpotApplied = true;
6366
+ notes.push(
6367
+ `Spot pricing applied from live Azure Retail API (${savingsPct}% discount from on-demand)`
6368
+ );
6369
+ }
6370
+ }
6371
+ } catch (err) {
6372
+ logger.debug("Azure live spot lookup failed, using fallback discount factor", {
6373
+ err: err instanceof Error ? err.message : String(err)
6374
+ });
6375
+ }
6376
+ }
6377
+ if (!liveSpotApplied && targetProvider === "aws") {
6378
+ try {
6379
+ const awsProvider = pricingEngine.getProvider("aws");
6380
+ if (typeof awsProvider.getSpotFactor === "function") {
6381
+ const liveFactor = await awsProvider.getSpotFactor(
6382
+ effectiveInstance,
6383
+ targetRegion,
6384
+ resource.attributes.os
6385
+ );
6386
+ if (liveFactor !== null && liveFactor > 0 && liveFactor < 1) {
6387
+ const savingsPct = Math.round((1 - liveFactor) * 100);
6388
+ hourlyPrice = hourlyPrice * liveFactor;
6389
+ pricingSource = "live";
6390
+ liveSpotApplied = true;
6391
+ notes.push(
6392
+ `Spot pricing applied from live AWS Spot Advisor (${savingsPct}% discount from on-demand)`
6393
+ );
6394
+ }
6395
+ }
6396
+ } catch (err) {
6397
+ logger.debug("AWS live spot lookup failed, using fallback discount factor", {
6398
+ err: err instanceof Error ? err.message : String(err)
6399
+ });
6400
+ }
6401
+ }
6402
+ if (!liveSpotApplied) {
6403
+ const family = classifyInstanceFamily(effectiveInstance, targetProvider);
6404
+ const providerFactors = SPOT_DISCOUNT_FACTORS[targetProvider] ?? SPOT_DISCOUNT_FACTORS.aws;
6405
+ const factor = providerFactors[family] ?? providerFactors.general ?? 0.35;
6406
+ const savingsPct = Math.round((1 - factor) * 100);
6407
+ hourlyPrice = hourlyPrice * factor;
6408
+ pricingSource = "spot-estimate";
6409
+ notes.push(`Spot pricing applied (${savingsPct}% discount from on-demand)`);
6410
+ }
5292
6411
  }
5293
6412
  computeMonthlyCost = hourlyPrice * monthlyHours;
5294
6413
  breakdown.push({
@@ -5327,20 +6446,16 @@ async function calculateComputeCost(resource, targetProvider, targetRegion, pric
5327
6446
  }
5328
6447
  }
5329
6448
  const totalMonthly = computeMonthlyCost + storageMonthlyCost;
5330
- return {
5331
- resource_id: resource.id,
5332
- resource_type: resource.type,
5333
- resource_name: resource.name,
6449
+ return makeCostEstimate({
6450
+ resource,
5334
6451
  provider: targetProvider,
5335
6452
  region: targetRegion,
5336
- monthly_cost: totalMonthly,
5337
- yearly_cost: totalMonthly * 12,
5338
- currency: "USD",
6453
+ monthlyCost: totalMonthly,
5339
6454
  breakdown,
5340
6455
  confidence,
5341
6456
  notes,
5342
- pricing_source: pricingSource
5343
- };
6457
+ pricingSource
6458
+ });
5344
6459
  }
5345
6460
 
5346
6461
  // src/calculator/database.ts
@@ -5443,20 +6558,16 @@ async function calculateDatabaseCost(resource, targetProvider, targetRegion, pri
5443
6558
  }
5444
6559
  }
5445
6560
  const totalMonthly = instanceMonthlyCost + storageMonthlyCost;
5446
- return {
5447
- resource_id: resource.id,
5448
- resource_type: resource.type,
5449
- resource_name: resource.name,
6561
+ return makeCostEstimate({
6562
+ resource,
5450
6563
  provider: targetProvider,
5451
6564
  region: targetRegion,
5452
- monthly_cost: totalMonthly,
5453
- yearly_cost: totalMonthly * 12,
5454
- currency: "USD",
6565
+ monthlyCost: totalMonthly,
5455
6566
  breakdown,
5456
6567
  confidence: mappedInstance ? "high" : "low",
5457
6568
  notes,
5458
- pricing_source: pricingSource
5459
- };
6569
+ pricingSource
6570
+ });
5460
6571
  }
5461
6572
 
5462
6573
  // src/calculator/storage.ts
@@ -5535,20 +6646,16 @@ async function calculateStorageCost(resource, targetProvider, targetRegion, pric
5535
6646
  targetRegion
5536
6647
  });
5537
6648
  }
5538
- return {
5539
- resource_id: resource.id,
5540
- resource_type: resource.type,
5541
- resource_name: resource.name,
6649
+ return makeCostEstimate({
6650
+ resource,
5542
6651
  provider: targetProvider,
5543
6652
  region: targetRegion,
5544
- monthly_cost: totalMonthly,
5545
- yearly_cost: totalMonthly * 12,
5546
- currency: "USD",
6653
+ monthlyCost: totalMonthly,
5547
6654
  breakdown,
5548
6655
  confidence: storagePrice ? "high" : "low",
5549
6656
  notes,
5550
- pricing_source: pricingSource
5551
- };
6657
+ pricingSource
6658
+ });
5552
6659
  }
5553
6660
 
5554
6661
  // src/calculator/network.ts
@@ -5598,20 +6705,16 @@ async function calculateNatGatewayCost(resource, targetProvider, targetRegion, p
5598
6705
  } else {
5599
6706
  notes.push(`No pricing data found for ${resource.type} in ${targetRegion}`);
5600
6707
  }
5601
- return {
5602
- resource_id: resource.id,
5603
- resource_type: resource.type,
5604
- resource_name: resource.name,
6708
+ return makeCostEstimate({
6709
+ resource,
5605
6710
  provider: targetProvider,
5606
6711
  region: targetRegion,
5607
- monthly_cost: totalMonthly,
5608
- yearly_cost: totalMonthly * 12,
5609
- currency: "USD",
6712
+ monthlyCost: totalMonthly,
5610
6713
  breakdown,
5611
6714
  confidence: natPrice ? "medium" : "low",
5612
6715
  notes,
5613
- pricing_source: natPricingSource
5614
- };
6716
+ pricingSource: natPricingSource
6717
+ });
5615
6718
  }
5616
6719
  async function calculateLoadBalancerCost(resource, targetProvider, targetRegion, pricingEngine, monthlyHours = 730) {
5617
6720
  const notes = [];
@@ -5643,20 +6746,16 @@ async function calculateLoadBalancerCost(resource, targetProvider, targetRegion,
5643
6746
  } else {
5644
6747
  notes.push(`No pricing data found for ${resource.type} in ${targetRegion}`);
5645
6748
  }
5646
- return {
5647
- resource_id: resource.id,
5648
- resource_type: resource.type,
5649
- resource_name: resource.name,
6749
+ return makeCostEstimate({
6750
+ resource,
5650
6751
  provider: targetProvider,
5651
6752
  region: targetRegion,
5652
- monthly_cost: totalMonthly,
5653
- yearly_cost: totalMonthly * 12,
5654
- currency: "USD",
6753
+ monthlyCost: totalMonthly,
5655
6754
  breakdown,
5656
6755
  confidence: lbPrice ? "medium" : "low",
5657
6756
  notes,
5658
- pricing_source: lbPricingSource
5659
- };
6757
+ pricingSource: lbPricingSource
6758
+ });
5660
6759
  }
5661
6760
 
5662
6761
  // src/calculator/kubernetes.ts
@@ -5727,20 +6826,16 @@ async function calculateKubernetesCost(resource, targetProvider, targetRegion, p
5727
6826
  );
5728
6827
  }
5729
6828
  }
5730
- return {
5731
- resource_id: resource.id,
5732
- resource_type: resource.type,
5733
- resource_name: resource.name,
6829
+ return makeCostEstimate({
6830
+ resource,
5734
6831
  provider: targetProvider,
5735
6832
  region: targetRegion,
5736
- monthly_cost: totalMonthly,
5737
- yearly_cost: totalMonthly * 12,
5738
- currency: "USD",
6833
+ monthlyCost: totalMonthly,
5739
6834
  breakdown,
5740
6835
  confidence: k8sPrice ? "medium" : "low",
5741
6836
  notes,
5742
- pricing_source: pricingSource
5743
- };
6837
+ pricingSource
6838
+ });
5744
6839
  }
5745
6840
 
5746
6841
  // src/calculator/serverless.ts
@@ -5795,20 +6890,16 @@ function calculateLambdaCost(resource, targetProvider, targetRegion) {
5795
6890
  }
5796
6891
  const totalMonthly = requestCost + computeCost;
5797
6892
  void timeoutSec;
5798
- return {
5799
- resource_id: resource.id,
5800
- resource_type: resource.type,
5801
- resource_name: resource.name,
6893
+ return makeCostEstimate({
6894
+ resource,
5802
6895
  provider: targetProvider,
5803
6896
  region: targetRegion,
5804
- monthly_cost: totalMonthly,
5805
- yearly_cost: totalMonthly * 12,
5806
- currency: "USD",
6897
+ monthlyCost: totalMonthly,
5807
6898
  breakdown,
5808
6899
  confidence: "medium",
5809
6900
  notes,
5810
- pricing_source: "fallback"
5811
- };
6901
+ pricingSource: "fallback"
6902
+ });
5812
6903
  }
5813
6904
  var DYNAMO_ON_DEMAND_WRITE_PER_MILLION = 1.25;
5814
6905
  var DYNAMO_ON_DEMAND_READ_PER_MILLION = 0.25;
@@ -5878,20 +6969,16 @@ function calculateDynamoDbCost(resource, targetProvider, targetRegion) {
5878
6969
  monthly_cost: storageCost
5879
6970
  });
5880
6971
  totalMonthly += storageCost;
5881
- return {
5882
- resource_id: resource.id,
5883
- resource_type: resource.type,
5884
- resource_name: resource.name,
6972
+ return makeCostEstimate({
6973
+ resource,
5885
6974
  provider: targetProvider,
5886
6975
  region: targetRegion,
5887
- monthly_cost: totalMonthly,
5888
- yearly_cost: totalMonthly * 12,
5889
- currency: "USD",
6976
+ monthlyCost: totalMonthly,
5890
6977
  breakdown,
5891
6978
  confidence: "medium",
5892
6979
  notes,
5893
- pricing_source: "fallback"
5894
- };
6980
+ pricingSource: "fallback"
6981
+ });
5895
6982
  }
5896
6983
  var SQS_STANDARD_PER_MILLION = 0.4;
5897
6984
  var SQS_FIFO_PER_MILLION = 0.5;
@@ -5916,20 +7003,16 @@ function calculateSqsCost(resource, targetProvider, targetRegion) {
5916
7003
  );
5917
7004
  }
5918
7005
  notes.push(`SQS ${queueType} queue pricing at $${pricePerMillion}/million requests`);
5919
- return {
5920
- resource_id: resource.id,
5921
- resource_type: resource.type,
5922
- resource_name: resource.name,
7006
+ return makeCostEstimate({
7007
+ resource,
5923
7008
  provider: targetProvider,
5924
7009
  region: targetRegion,
5925
- monthly_cost: totalMonthly,
5926
- yearly_cost: totalMonthly * 12,
5927
- currency: "USD",
7010
+ monthlyCost: totalMonthly,
5928
7011
  breakdown,
5929
7012
  confidence: "medium",
5930
7013
  notes,
5931
- pricing_source: "fallback"
5932
- };
7014
+ pricingSource: "fallback"
7015
+ });
5933
7016
  }
5934
7017
 
5935
7018
  // src/calculator/cache.ts
@@ -5986,20 +7069,16 @@ function calculateElastiCacheCost(resource, targetProvider, targetRegion) {
5986
7069
  } else {
5987
7070
  notes.push(`No pricing data found for ElastiCache node type ${nodeType}; cost reported as $0`);
5988
7071
  }
5989
- return {
5990
- resource_id: resource.id,
5991
- resource_type: resource.type,
5992
- resource_name: resource.name,
7072
+ return makeCostEstimate({
7073
+ resource,
5993
7074
  provider: targetProvider,
5994
7075
  region: targetRegion,
5995
- monthly_cost: totalMonthly,
5996
- yearly_cost: totalMonthly * 12,
5997
- currency: "USD",
7076
+ monthlyCost: totalMonthly,
5998
7077
  breakdown,
5999
7078
  confidence: basePrice !== void 0 ? "medium" : "low",
6000
7079
  notes,
6001
- pricing_source: pricingSource
6002
- };
7080
+ pricingSource
7081
+ });
6003
7082
  }
6004
7083
  var AZURE_REDIS_PRICES = {
6005
7084
  // Basic (single node, no SLA)
@@ -6052,20 +7131,16 @@ function calculateAzureRedisCacheCost(resource, targetProvider, targetRegion) {
6052
7131
  `No pricing found for Azure Redis Cache ${sku} ${capacityStr.toUpperCase()}; cost reported as $0`
6053
7132
  );
6054
7133
  }
6055
- return {
6056
- resource_id: resource.id,
6057
- resource_type: resource.type,
6058
- resource_name: resource.name,
7134
+ return makeCostEstimate({
7135
+ resource,
6059
7136
  provider: targetProvider,
6060
7137
  region: targetRegion,
6061
- monthly_cost: totalMonthly,
6062
- yearly_cost: totalMonthly * 12,
6063
- currency: "USD",
7138
+ monthlyCost: totalMonthly,
6064
7139
  breakdown,
6065
7140
  confidence: hourlyPrice !== void 0 ? "medium" : "low",
6066
7141
  notes,
6067
- pricing_source: pricingSource
6068
- };
7142
+ pricingSource
7143
+ });
6069
7144
  }
6070
7145
  var GCP_REDIS_PRICES = {
6071
7146
  basic: 0.049,
@@ -6089,20 +7164,16 @@ function calculateGcpRedisCost(resource, targetProvider, targetRegion) {
6089
7164
  notes.push(
6090
7165
  `GCP Memorystore Redis: ${normalizedTier} tier, ${memorySizeGb}GB \u2014 $${pricePerGbHour}/GB/hr`
6091
7166
  );
6092
- return {
6093
- resource_id: resource.id,
6094
- resource_type: resource.type,
6095
- resource_name: resource.name,
7167
+ return makeCostEstimate({
7168
+ resource,
6096
7169
  provider: targetProvider,
6097
7170
  region: targetRegion,
6098
- monthly_cost: totalMonthly,
6099
- yearly_cost: totalMonthly * 12,
6100
- currency: "USD",
7171
+ monthlyCost: totalMonthly,
6101
7172
  breakdown,
6102
7173
  confidence: "medium",
6103
7174
  notes,
6104
- pricing_source: "fallback"
6105
- };
7175
+ pricingSource: "fallback"
7176
+ });
6106
7177
  }
6107
7178
  var CLOUDFRONT_DATA_FIRST_10TB_PER_GB = 0.085;
6108
7179
  var DEFAULT_CLOUDFRONT_TRANSFER_GB = 100;
@@ -6142,20 +7213,16 @@ function calculateCloudFrontCost(resource, targetProvider, targetRegion) {
6142
7213
  notes.push(
6143
7214
  `CloudFront ${priceClass} \u2014 HTTPS request charges excluded (runtime traffic data required)`
6144
7215
  );
6145
- return {
6146
- resource_id: resource.id,
6147
- resource_type: resource.type,
6148
- resource_name: resource.name,
7216
+ return makeCostEstimate({
7217
+ resource,
6149
7218
  provider: targetProvider,
6150
7219
  region: targetRegion,
6151
- monthly_cost: totalMonthly,
6152
- yearly_cost: totalMonthly * 12,
6153
- currency: "USD",
7220
+ monthlyCost: totalMonthly,
6154
7221
  breakdown,
6155
7222
  confidence: "medium",
6156
7223
  notes,
6157
- pricing_source: "fallback"
6158
- };
7224
+ pricingSource: "fallback"
7225
+ });
6159
7226
  }
6160
7227
 
6161
7228
  // src/calculator/paas.ts
@@ -6222,20 +7289,16 @@ function calculateAppServicePlanCost(resource, targetProvider, targetRegion) {
6222
7289
  `No pricing found for App Service Plan SKU "${skuName || skuSize || skuTier}"; cost reported as $0`
6223
7290
  );
6224
7291
  }
6225
- return {
6226
- resource_id: resource.id,
6227
- resource_type: resource.type,
6228
- resource_name: resource.name,
7292
+ return makeCostEstimate({
7293
+ resource,
6229
7294
  provider: targetProvider,
6230
7295
  region: targetRegion,
6231
- monthly_cost: totalMonthly,
6232
- yearly_cost: totalMonthly * 12,
6233
- currency: "USD",
7296
+ monthlyCost: totalMonthly,
6234
7297
  breakdown,
6235
7298
  confidence: hourlyPrice !== void 0 ? "medium" : "low",
6236
7299
  notes,
6237
- pricing_source: pricingSource
6238
- };
7300
+ pricingSource
7301
+ });
6239
7302
  }
6240
7303
  var COSMOS_SERVERLESS_PER_MILLION_RU = 0.25;
6241
7304
  var COSMOS_PROVISIONED_PER_100_RU_PER_HR = 8e-3;
@@ -6293,20 +7356,16 @@ function calculateCosmosDbCost(resource, targetProvider, targetRegion) {
6293
7356
  });
6294
7357
  totalMonthly += storageCost;
6295
7358
  }
6296
- return {
6297
- resource_id: resource.id,
6298
- resource_type: resource.type,
6299
- resource_name: resource.name,
7359
+ return makeCostEstimate({
7360
+ resource,
6300
7361
  provider: targetProvider,
6301
7362
  region: targetRegion,
6302
- monthly_cost: totalMonthly,
6303
- yearly_cost: totalMonthly * 12,
6304
- currency: "USD",
7363
+ monthlyCost: totalMonthly,
6305
7364
  breakdown,
6306
7365
  confidence: "medium",
6307
7366
  notes,
6308
- pricing_source: pricingSource
6309
- };
7367
+ pricingSource
7368
+ });
6310
7369
  }
6311
7370
  var AZURE_FUNC_PER_MILLION_EXECUTIONS = 0.2;
6312
7371
  var AZURE_FUNC_GB_SECOND_PRICE = 16e-6;
@@ -6343,20 +7402,16 @@ function calculateAzureFunctionCost(resource, targetProvider, targetRegion) {
6343
7402
  );
6344
7403
  }
6345
7404
  notes.push("Azure Function App on Consumption plan (dedicated plan not priced separately)");
6346
- return {
6347
- resource_id: resource.id,
6348
- resource_type: resource.type,
6349
- resource_name: resource.name,
7405
+ return makeCostEstimate({
7406
+ resource,
6350
7407
  provider: targetProvider,
6351
7408
  region: targetRegion,
6352
- monthly_cost: totalMonthly,
6353
- yearly_cost: totalMonthly * 12,
6354
- currency: "USD",
7409
+ monthlyCost: totalMonthly,
6355
7410
  breakdown,
6356
7411
  confidence: "medium",
6357
7412
  notes,
6358
- pricing_source: "fallback"
6359
- };
7413
+ pricingSource: "fallback"
7414
+ });
6360
7415
  }
6361
7416
  var CLOUD_RUN_VCPU_PER_SECOND = 24e-6;
6362
7417
  var CLOUD_RUN_MEMORY_GIB_PER_SECOND = 25e-7;
@@ -6429,20 +7484,16 @@ function calculateCloudRunCost(resource, targetProvider, targetRegion) {
6429
7484
  );
6430
7485
  }
6431
7486
  notes.push(`Cloud Run: ${vcpus} vCPU, ${memoryGib.toFixed(2)}GiB memory`);
6432
- return {
6433
- resource_id: resource.id,
6434
- resource_type: resource.type,
6435
- resource_name: resource.name,
7487
+ return makeCostEstimate({
7488
+ resource,
6436
7489
  provider: targetProvider,
6437
7490
  region: targetRegion,
6438
- monthly_cost: totalMonthly,
6439
- yearly_cost: totalMonthly * 12,
6440
- currency: "USD",
7491
+ monthlyCost: totalMonthly,
6441
7492
  breakdown,
6442
7493
  confidence: "medium",
6443
7494
  notes,
6444
- pricing_source: "fallback"
6445
- };
7495
+ pricingSource: "fallback"
7496
+ });
6446
7497
  }
6447
7498
  var BIGQUERY_QUERY_PER_TB = 5;
6448
7499
  var BIGQUERY_STORAGE_PER_GB = 0.02;
@@ -6475,24 +7526,20 @@ function calculateBigQueryCost(resource, targetProvider, targetRegion) {
6475
7526
  `Default assumption: ${DEFAULT_BIGQUERY_QUERY_TB}TB queries/month \u2014 provide monthly_query_tb attribute for accuracy`
6476
7527
  );
6477
7528
  }
6478
- if (resource.attributes.location) {
7529
+ if (typeof resource.attributes.location === "string" && resource.attributes.location) {
6479
7530
  notes.push(`BigQuery location: ${resource.attributes.location}`);
6480
7531
  }
6481
7532
  notes.push("BigQuery on-demand pricing; flat-rate/reservations not included");
6482
- return {
6483
- resource_id: resource.id,
6484
- resource_type: resource.type,
6485
- resource_name: resource.name,
7533
+ return makeCostEstimate({
7534
+ resource,
6486
7535
  provider: targetProvider,
6487
7536
  region: targetRegion,
6488
- monthly_cost: totalMonthly,
6489
- yearly_cost: totalMonthly * 12,
6490
- currency: "USD",
7537
+ monthlyCost: totalMonthly,
6491
7538
  breakdown,
6492
7539
  confidence: "medium",
6493
7540
  notes,
6494
- pricing_source: "fallback"
6495
- };
7541
+ pricingSource: "fallback"
7542
+ });
6496
7543
  }
6497
7544
  var GCF_PER_MILLION_INVOCATIONS = 0.4;
6498
7545
  var GCF_GB_SECOND_PRICE = 25e-7;
@@ -6528,114 +7575,38 @@ function calculateGcpFunctionCost(resource, targetProvider, targetRegion) {
6528
7575
  `Default assumption: ${DEFAULT_GCF_INVOCATIONS.toLocaleString()} invocations/month, ${DEFAULT_GCF_DURATION_MS}ms avg, ${DEFAULT_GCF_MEMORY_MB}MB memory`
6529
7576
  );
6530
7577
  }
6531
- if (resource.attributes.runtime) {
7578
+ if (typeof resource.attributes.runtime === "string" && resource.attributes.runtime) {
6532
7579
  notes.push(`Cloud Function runtime: ${resource.attributes.runtime}`);
6533
7580
  }
6534
- return {
6535
- resource_id: resource.id,
6536
- resource_type: resource.type,
6537
- resource_name: resource.name,
7581
+ return makeCostEstimate({
7582
+ resource,
6538
7583
  provider: targetProvider,
6539
7584
  region: targetRegion,
6540
- monthly_cost: totalMonthly,
6541
- yearly_cost: totalMonthly * 12,
6542
- currency: "USD",
7585
+ monthlyCost: totalMonthly,
6543
7586
  breakdown,
6544
7587
  confidence: "medium",
6545
7588
  notes,
6546
- pricing_source: "fallback"
6547
- };
7589
+ pricingSource: "fallback"
7590
+ });
6548
7591
  }
6549
7592
 
6550
7593
  // src/calculator/data-transfer.ts
6551
7594
  var DEFAULT_EGRESS_GB = 100;
6552
- var AWS_EGRESS_PER_GB = 0.09;
6553
- var AZURE_EGRESS_PER_GB = 0.087;
6554
- var GCP_EGRESS_PER_GB = 0.085;
6555
- var AWS_REGION_MULTIPLIERS = {
6556
- "us-east-1": 1,
6557
- "us-east-2": 1,
6558
- "us-west-1": 1,
6559
- "us-west-2": 1,
6560
- "eu-west-1": 1,
6561
- "eu-west-2": 1,
6562
- "eu-central-1": 1,
6563
- "ap-southeast-1": 1.11,
6564
- // $0.10/GB
6565
- "ap-southeast-2": 1.11,
6566
- "ap-northeast-1": 1.11,
6567
- "ap-northeast-2": 1.11,
6568
- "ap-south-1": 1.11,
6569
- "sa-east-1": 1.31,
6570
- // $0.118/GB
6571
- "ca-central-1": 1,
6572
- "eu-north-1": 1,
6573
- "me-south-1": 1.11,
6574
- "af-south-1": 1.16
6575
- // $0.1046/GB
6576
- };
6577
- var AZURE_REGION_MULTIPLIERS = {
6578
- eastus: 1,
6579
- eastus2: 1,
6580
- westus: 1,
6581
- westus2: 1,
6582
- westus3: 1,
6583
- centralus: 1,
6584
- northeurope: 1,
6585
- westeurope: 1,
6586
- uksouth: 1,
6587
- ukwest: 1,
6588
- germanywestcentral: 1,
6589
- francecentral: 1,
6590
- eastasia: 1.16,
6591
- // Zone 2 rate
6592
- southeastasia: 1.16,
6593
- japaneast: 1.16,
6594
- koreacentral: 1.16,
6595
- australiaeast: 1.24,
6596
- // Zone 3 rate
6597
- brazilsouth: 1.31,
6598
- southafricanorth: 1.31
6599
- };
6600
- var GCP_REGION_MULTIPLIERS = {
6601
- "us-central1": 1,
6602
- "us-east1": 1,
6603
- "us-east4": 1,
6604
- "us-west1": 1,
6605
- "us-west2": 1,
6606
- "us-west3": 1,
6607
- "us-west4": 1,
6608
- "europe-west1": 1,
6609
- "europe-west2": 1,
6610
- "europe-west3": 1,
6611
- "europe-west4": 1,
6612
- "europe-west6": 1,
6613
- "europe-north1": 1,
6614
- "northamerica-northeast1": 1,
6615
- "southamerica-east1": 1.59,
6616
- // $0.135/GB
6617
- "asia-east1": 1.41,
6618
- // $0.12/GB (APAC)
6619
- "asia-east2": 1.41,
6620
- "asia-northeast1": 1.41,
6621
- "asia-northeast2": 1.41,
6622
- "asia-northeast3": 1.41,
6623
- "asia-south1": 1.41,
6624
- "asia-southeast1": 1.41,
6625
- "asia-southeast2": 1.41,
6626
- "australia-southeast1": 1.41,
6627
- "australia-southeast2": 1.41
7595
+ var EGRESS_PRICING = {
7596
+ aws: {
7597
+ ratePerGb: 0.09,
7598
+ note: "Data transfer costs cover internet egress only; intra-region and inter-AZ traffic charges are excluded from this estimate"
7599
+ },
7600
+ azure: {
7601
+ ratePerGb: 0.087,
7602
+ note: "Data transfer costs cover internet egress only; intra-region traffic is free on Azure and excluded from this estimate"
7603
+ },
7604
+ gcp: {
7605
+ ratePerGb: 0.085,
7606
+ note: "Data transfer costs cover internet egress only; intra-region and Google network traffic charges are excluded from this estimate"
7607
+ }
6628
7608
  };
6629
- function awsRegionMultiplier(region) {
6630
- return AWS_REGION_MULTIPLIERS[region.toLowerCase()] ?? 1;
6631
- }
6632
- function azureRegionMultiplier(region) {
6633
- return AZURE_REGION_MULTIPLIERS[region.toLowerCase()] ?? 1;
6634
- }
6635
- function gcpRegionMultiplier(region) {
6636
- return GCP_REGION_MULTIPLIERS[region.toLowerCase()] ?? 1;
6637
- }
6638
- async function calculateAwsDataTransferCost(resource, targetRegion) {
7609
+ function calculateEgressCost(resource, provider, targetRegion) {
6639
7610
  const notes = [];
6640
7611
  const breakdown = [];
6641
7612
  const egressGb = resource.attributes.monthly_egress_gb ?? DEFAULT_EGRESS_GB;
@@ -6644,8 +7615,8 @@ async function calculateAwsDataTransferCost(resource, targetRegion) {
6644
7615
  `Data transfer cost estimated at ${egressGb} GB/month egress; actual costs depend on traffic volume and destination`
6645
7616
  );
6646
7617
  }
6647
- const multiplier = awsRegionMultiplier(targetRegion);
6648
- const ratePerGb = AWS_EGRESS_PER_GB * multiplier;
7618
+ const pricing = EGRESS_PRICING[provider];
7619
+ const ratePerGb = pricing.ratePerGb * regionMultiplier(provider, targetRegion);
6649
7620
  const totalMonthly = ratePerGb * egressGb;
6650
7621
  breakdown.push({
6651
7622
  description: `Internet egress (${egressGb} GB/month, estimated)`,
@@ -6654,97 +7625,26 @@ async function calculateAwsDataTransferCost(resource, targetRegion) {
6654
7625
  unit_price: ratePerGb,
6655
7626
  monthly_cost: totalMonthly
6656
7627
  });
6657
- notes.push(
6658
- "Data transfer costs cover internet egress only; intra-region and inter-AZ traffic charges are excluded from this estimate"
6659
- );
6660
- return {
6661
- resource_id: resource.id,
6662
- resource_type: resource.type,
6663
- resource_name: resource.name,
6664
- provider: "aws",
7628
+ notes.push(pricing.note);
7629
+ return makeCostEstimate({
7630
+ resource,
7631
+ provider,
6665
7632
  region: targetRegion,
6666
- monthly_cost: Math.round(totalMonthly * 100) / 100,
6667
- yearly_cost: Math.round(totalMonthly * 12 * 100) / 100,
6668
- currency: "USD",
7633
+ monthlyCost: totalMonthly,
6669
7634
  breakdown,
6670
7635
  confidence: "low",
6671
7636
  notes,
6672
- pricing_source: "fallback"
6673
- };
6674
- }
6675
- async function calculateAzureDataTransferCost(resource, targetRegion) {
6676
- const notes = [];
6677
- const breakdown = [];
6678
- const egressGb = resource.attributes.monthly_egress_gb ?? DEFAULT_EGRESS_GB;
6679
- if (!resource.attributes.monthly_egress_gb || resource.attributes.monthly_egress_gb <= 0) {
6680
- notes.push(
6681
- `Data transfer cost estimated at ${egressGb} GB/month egress; actual costs depend on traffic volume and destination`
6682
- );
6683
- }
6684
- const multiplier = azureRegionMultiplier(targetRegion);
6685
- const ratePerGb = AZURE_EGRESS_PER_GB * multiplier;
6686
- const totalMonthly = ratePerGb * egressGb;
6687
- breakdown.push({
6688
- description: `Internet egress (${egressGb} GB/month, estimated)`,
6689
- unit: "GB",
6690
- quantity: egressGb,
6691
- unit_price: ratePerGb,
6692
- monthly_cost: totalMonthly
7637
+ pricingSource: "fallback"
6693
7638
  });
6694
- notes.push(
6695
- "Data transfer costs cover internet egress only; intra-region traffic is free on Azure and excluded from this estimate"
6696
- );
6697
- return {
6698
- resource_id: resource.id,
6699
- resource_type: resource.type,
6700
- resource_name: resource.name,
6701
- provider: "azure",
6702
- region: targetRegion,
6703
- monthly_cost: Math.round(totalMonthly * 100) / 100,
6704
- yearly_cost: Math.round(totalMonthly * 12 * 100) / 100,
6705
- currency: "USD",
6706
- breakdown,
6707
- confidence: "low",
6708
- notes,
6709
- pricing_source: "fallback"
6710
- };
6711
7639
  }
6712
- async function calculateGcpDataTransferCost(resource, targetRegion) {
6713
- const notes = [];
6714
- const breakdown = [];
6715
- const egressGb = resource.attributes.monthly_egress_gb ?? DEFAULT_EGRESS_GB;
6716
- if (!resource.attributes.monthly_egress_gb || resource.attributes.monthly_egress_gb <= 0) {
6717
- notes.push(
6718
- `Data transfer cost estimated at ${egressGb} GB/month egress; actual costs depend on traffic volume and destination`
6719
- );
6720
- }
6721
- const multiplier = gcpRegionMultiplier(targetRegion);
6722
- const ratePerGb = GCP_EGRESS_PER_GB * multiplier;
6723
- const totalMonthly = ratePerGb * egressGb;
6724
- breakdown.push({
6725
- description: `Internet egress (${egressGb} GB/month, estimated)`,
6726
- unit: "GB",
6727
- quantity: egressGb,
6728
- unit_price: ratePerGb,
6729
- monthly_cost: totalMonthly
6730
- });
6731
- notes.push(
6732
- "Data transfer costs cover internet egress only; intra-region and Google network traffic charges are excluded from this estimate"
6733
- );
6734
- return {
6735
- resource_id: resource.id,
6736
- resource_type: resource.type,
6737
- resource_name: resource.name,
6738
- provider: "gcp",
6739
- region: targetRegion,
6740
- monthly_cost: Math.round(totalMonthly * 100) / 100,
6741
- yearly_cost: Math.round(totalMonthly * 12 * 100) / 100,
6742
- currency: "USD",
6743
- breakdown,
6744
- confidence: "low",
6745
- notes,
6746
- pricing_source: "fallback"
6747
- };
7640
+ function calculateAwsDataTransferCost(resource, targetRegion) {
7641
+ return calculateEgressCost(resource, "aws", targetRegion);
7642
+ }
7643
+ function calculateAzureDataTransferCost(resource, targetRegion) {
7644
+ return calculateEgressCost(resource, "azure", targetRegion);
7645
+ }
7646
+ function calculateGcpDataTransferCost(resource, targetRegion) {
7647
+ return calculateEgressCost(resource, "gcp", targetRegion);
6748
7648
  }
6749
7649
 
6750
7650
  // src/calculator/container-registry.ts
@@ -6795,20 +7695,16 @@ function calculateContainerRegistryCost(resource, targetProvider, targetRegion)
6795
7695
  monthly_cost: storageCost
6796
7696
  });
6797
7697
  const totalMonthly = storageCost;
6798
- return {
6799
- resource_id: resource.id,
6800
- resource_type: resource.type,
6801
- resource_name: resource.name,
7698
+ return makeCostEstimate({
7699
+ resource,
6802
7700
  provider: targetProvider,
6803
7701
  region: targetRegion,
6804
- monthly_cost: totalMonthly,
6805
- yearly_cost: totalMonthly * 12,
6806
- currency: "USD",
7702
+ monthlyCost: totalMonthly,
6807
7703
  breakdown,
6808
7704
  confidence: hasExplicitStorage ? "high" : "medium",
6809
7705
  notes,
6810
- pricing_source: "bundled"
6811
- };
7706
+ pricingSource: "bundled"
7707
+ });
6812
7708
  }
6813
7709
 
6814
7710
  // src/calculator/secrets.ts
@@ -6903,20 +7799,16 @@ function calculateSecretsCost(resource, targetProvider, targetRegion) {
6903
7799
  });
6904
7800
  totalMonthly += accessOpFee;
6905
7801
  }
6906
- return {
6907
- resource_id: resource.id,
6908
- resource_type: resource.type,
6909
- resource_name: resource.name,
7802
+ return makeCostEstimate({
7803
+ resource,
6910
7804
  provider: targetProvider,
6911
7805
  region: targetRegion,
6912
- monthly_cost: totalMonthly,
6913
- yearly_cost: totalMonthly * 12,
6914
- currency: "USD",
7806
+ monthlyCost: totalMonthly,
6915
7807
  breakdown,
6916
7808
  confidence: hasExplicitSecretCount ? "high" : "medium",
6917
7809
  notes,
6918
- pricing_source: "bundled"
6919
- };
7810
+ pricingSource: "bundled"
7811
+ });
6920
7812
  }
6921
7813
 
6922
7814
  // src/calculator/dns.ts
@@ -6970,20 +7862,16 @@ function calculateDnsCost(resource, targetProvider, targetRegion) {
6970
7862
  monthly_cost: queryCost
6971
7863
  });
6972
7864
  const totalMonthly = zonePrice + queryCost;
6973
- return {
6974
- resource_id: resource.id,
6975
- resource_type: resource.type,
6976
- resource_name: resource.name,
7865
+ return makeCostEstimate({
7866
+ resource,
6977
7867
  provider: targetProvider,
6978
7868
  region: targetRegion,
6979
- monthly_cost: totalMonthly,
6980
- yearly_cost: totalMonthly * 12,
6981
- currency: "USD",
7869
+ monthlyCost: totalMonthly,
6982
7870
  breakdown,
6983
7871
  confidence: hasExplicitQueries ? "high" : "medium",
6984
7872
  notes,
6985
- pricing_source: "bundled"
6986
- };
7873
+ pricingSource: "bundled"
7874
+ });
6987
7875
  }
6988
7876
 
6989
7877
  // src/calculator/api-gateway.ts
@@ -7104,20 +7992,16 @@ function calculateApiGatewayCost(resource, targetProvider, targetRegion) {
7104
7992
  );
7105
7993
  }
7106
7994
  }
7107
- return {
7108
- resource_id: resource.id,
7109
- resource_type: resource.type,
7110
- resource_name: resource.name,
7995
+ return makeCostEstimate({
7996
+ resource,
7111
7997
  provider: targetProvider,
7112
7998
  region: targetRegion,
7113
- monthly_cost: totalMonthly,
7114
- yearly_cost: totalMonthly * 12,
7115
- currency: "USD",
7999
+ monthlyCost: totalMonthly,
7116
8000
  breakdown,
7117
8001
  confidence: hasExplicitRequests ? "high" : "medium",
7118
8002
  notes,
7119
- pricing_source: "fallback"
7120
- };
8003
+ pricingSource: "fallback"
8004
+ });
7121
8005
  }
7122
8006
 
7123
8007
  // src/calculator/waf.ts
@@ -7231,20 +8115,16 @@ function calculateWafCost(resource, targetProvider, targetRegion) {
7231
8115
  totalMonthly += requestCost;
7232
8116
  notes.push("GCP equivalent: Cloud Armor security policy");
7233
8117
  }
7234
- return {
7235
- resource_id: resource.id,
7236
- resource_type: resource.type,
7237
- resource_name: resource.name,
8118
+ return makeCostEstimate({
8119
+ resource,
7238
8120
  provider: targetProvider,
7239
8121
  region: targetRegion,
7240
- monthly_cost: totalMonthly,
7241
- yearly_cost: totalMonthly * 12,
7242
- currency: "USD",
8122
+ monthlyCost: totalMonthly,
7243
8123
  breakdown,
7244
8124
  confidence: hasExplicitRules && hasExplicitRequests ? "high" : "medium",
7245
8125
  notes,
7246
- pricing_source: "fallback"
7247
- };
8126
+ pricingSource: "fallback"
8127
+ });
7248
8128
  }
7249
8129
 
7250
8130
  // src/calculator/search.ts
@@ -7339,20 +8219,16 @@ function calculateSearchCost(resource, targetProvider, targetRegion) {
7339
8219
  "GCP has no direct managed OpenSearch service; estimate based on comparable compute cost"
7340
8220
  );
7341
8221
  }
7342
- return {
7343
- resource_id: resource.id,
7344
- resource_type: resource.type,
7345
- resource_name: resource.name,
8222
+ return makeCostEstimate({
8223
+ resource,
7346
8224
  provider: targetProvider,
7347
8225
  region: targetRegion,
7348
- monthly_cost: totalMonthly,
7349
- yearly_cost: totalMonthly * 12,
7350
- currency: "USD",
8226
+ monthlyCost: totalMonthly,
7351
8227
  breakdown,
7352
8228
  confidence: targetProvider === "aws" ? "medium" : "low",
7353
8229
  notes,
7354
- pricing_source: "fallback"
7355
- };
8230
+ pricingSource: "fallback"
8231
+ });
7356
8232
  }
7357
8233
 
7358
8234
  // src/calculator/messaging.ts
@@ -7485,20 +8361,16 @@ function calculateMessagingCost(resource, targetProvider, targetRegion) {
7485
8361
  );
7486
8362
  }
7487
8363
  }
7488
- return {
7489
- resource_id: resource.id,
7490
- resource_type: resource.type,
7491
- resource_name: resource.name,
8364
+ return makeCostEstimate({
8365
+ resource,
7492
8366
  provider: targetProvider,
7493
8367
  region: targetRegion,
7494
- monthly_cost: totalMonthly,
7495
- yearly_cost: totalMonthly * 12,
7496
- currency: "USD",
8368
+ monthlyCost: totalMonthly,
7497
8369
  breakdown,
7498
8370
  confidence: hasExplicitMessages ? "high" : "medium",
7499
8371
  notes,
7500
- pricing_source: "fallback"
7501
- };
8372
+ pricingSource: "fallback"
8373
+ });
7502
8374
  }
7503
8375
  function calculateMqBrokerCost(resource, targetProvider, targetRegion) {
7504
8376
  const notes = [];
@@ -7535,20 +8407,16 @@ function calculateMqBrokerCost(resource, targetProvider, targetRegion) {
7535
8407
  unit_price: AWS_MQ_STORAGE_PER_GB,
7536
8408
  monthly_cost: storageCost
7537
8409
  });
7538
- return {
7539
- resource_id: resource.id,
7540
- resource_type: resource.type,
7541
- resource_name: resource.name,
8410
+ return makeCostEstimate({
8411
+ resource,
7542
8412
  provider: targetProvider,
7543
8413
  region: targetRegion,
7544
- monthly_cost: totalMonthly,
7545
- yearly_cost: totalMonthly * 12,
7546
- currency: "USD",
8414
+ monthlyCost: totalMonthly,
7547
8415
  breakdown,
7548
8416
  confidence: hourlyPrice !== void 0 ? "medium" : "low",
7549
8417
  notes,
7550
- pricing_source: "fallback"
7551
- };
8418
+ pricingSource: "fallback"
8419
+ });
7552
8420
  }
7553
8421
  function calculateEventHubCost(resource, targetProvider, targetRegion) {
7554
8422
  const notes = [];
@@ -7568,20 +8436,16 @@ function calculateEventHubCost(resource, targetProvider, targetRegion) {
7568
8436
  notes.push(
7569
8437
  `Azure Event Hubs: ${sku} tier, ${capacity} ${unitLabel}(s) \u2014 $${hourlyPrice}/${unitLabel}/hr`
7570
8438
  );
7571
- return {
7572
- resource_id: resource.id,
7573
- resource_type: resource.type,
7574
- resource_name: resource.name,
8439
+ return makeCostEstimate({
8440
+ resource,
7575
8441
  provider: targetProvider,
7576
8442
  region: targetRegion,
7577
- monthly_cost: totalMonthly,
7578
- yearly_cost: totalMonthly * 12,
7579
- currency: "USD",
8443
+ monthlyCost: totalMonthly,
7580
8444
  breakdown,
7581
8445
  confidence: "medium",
7582
8446
  notes,
7583
- pricing_source: "fallback"
7584
- };
8447
+ pricingSource: "fallback"
8448
+ });
7585
8449
  }
7586
8450
  function calculatePubSubSubscriptionCost(resource, targetProvider, targetRegion) {
7587
8451
  const notes = [];
@@ -7604,20 +8468,16 @@ function calculatePubSubSubscriptionCost(resource, targetProvider, targetRegion)
7604
8468
  notes.push(
7605
8469
  `GCP Pub/Sub subscription: $${GCP_PUBSUB_SUBSCRIPTION_PER_MILLION_OPS}/million ack operations`
7606
8470
  );
7607
- return {
7608
- resource_id: resource.id,
7609
- resource_type: resource.type,
7610
- resource_name: resource.name,
8471
+ return makeCostEstimate({
8472
+ resource,
7611
8473
  provider: targetProvider,
7612
8474
  region: targetRegion,
7613
- monthly_cost: opsCost,
7614
- yearly_cost: opsCost * 12,
7615
- currency: "USD",
8475
+ monthlyCost: opsCost,
7616
8476
  breakdown,
7617
8477
  confidence: hasExplicitMessages ? "high" : "medium",
7618
8478
  notes,
7619
- pricing_source: "fallback"
7620
- };
8479
+ pricingSource: "fallback"
8480
+ });
7621
8481
  }
7622
8482
 
7623
8483
  // src/calculator/ml-ai.ts
@@ -7735,20 +8595,16 @@ function calculateMlAiCost(resource, targetProvider, targetRegion) {
7735
8595
  "Azure ML endpoint pricing not yet supported \u2014 consider Azure Machine Learning compute instance costs"
7736
8596
  );
7737
8597
  }
7738
- return {
7739
- resource_id: resource.id,
7740
- resource_type: resource.type,
7741
- resource_name: resource.name,
8598
+ return makeCostEstimate({
8599
+ resource,
7742
8600
  provider: targetProvider,
7743
8601
  region: targetRegion,
7744
- monthly_cost: totalMonthly,
7745
- yearly_cost: totalMonthly * 12,
7746
- currency: "USD",
8602
+ monthlyCost: totalMonthly,
7747
8603
  breakdown,
7748
8604
  confidence: "low",
7749
8605
  notes,
7750
- pricing_source: "fallback"
7751
- };
8606
+ pricingSource: "fallback"
8607
+ });
7752
8608
  }
7753
8609
 
7754
8610
  // src/calculator/cost-engine.ts
@@ -8045,20 +8901,16 @@ var CostEngine = class {
8045
8901
  return calculatePubSubSubscriptionCost(resource, targetProvider, targetRegion);
8046
8902
  }
8047
8903
  logger.warn("CostEngine: unsupported resource type", { type, targetProvider });
8048
- return {
8049
- resource_id: resource.id,
8050
- resource_type: type,
8051
- resource_name: resource.name,
8904
+ return makeCostEstimate({
8905
+ resource,
8052
8906
  provider: targetProvider,
8053
8907
  region: targetRegion,
8054
- monthly_cost: 0,
8055
- yearly_cost: 0,
8056
- currency: "USD",
8908
+ monthlyCost: 0,
8057
8909
  breakdown: [],
8058
8910
  confidence: "low",
8059
8911
  notes: [`Resource type "${type}" is not yet supported by the cost engine`],
8060
- pricing_source: "fallback"
8061
- };
8912
+ pricingSource: "fallback"
8913
+ });
8062
8914
  }
8063
8915
  /**
8064
8916
  * Calculates costs for all resources and aggregates them into a CostBreakdown.
@@ -8124,7 +8976,7 @@ var CostEngine = class {
8124
8976
  name: `data-transfer-${estimate.provider}-${estimate.region}`,
8125
8977
  provider: estimate.provider,
8126
8978
  region: estimate.region,
8127
- attributes: { monthly_egress_gb: 100 },
8979
+ attributes: { monthly_egress_gb: DEFAULT_EGRESS_GB },
8128
8980
  tags: {},
8129
8981
  source_file: "synthetic"
8130
8982
  }
@@ -8152,6 +9004,12 @@ var CostEngine = class {
8152
9004
  }
8153
9005
  }
8154
9006
  const totalMonthly = estimates.reduce((sum, e) => sum + e.monthly_cost, 0);
9007
+ const estimatedEgressMonthly = estimates.filter((e) => DATA_TRANSFER_TYPES.has(e.resource_type)).reduce((sum, e) => sum + e.monthly_cost, 0);
9008
+ if (estimatedEgressMonthly > 0) {
9009
+ warnings.push(
9010
+ `total_monthly includes a synthetic estimate of ${DEFAULT_EGRESS_GB} GB/month internet egress ($${(Math.round(estimatedEgressMonthly * 100) / 100).toFixed(2)}/mo, USD) \u2014 see estimated_egress_monthly. Set CLOUDCOST_INCLUDE_DATA_TRANSFER=false to exclude it.`
9011
+ );
9012
+ }
8155
9013
  const budgetWarnings = this.evaluateBudget(totalMonthly, estimates);
8156
9014
  return {
8157
9015
  provider: targetProvider,
@@ -8165,6 +9023,9 @@ var CostEngine = class {
8165
9023
  by_resource: estimates,
8166
9024
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
8167
9025
  warnings,
9026
+ ...estimatedEgressMonthly > 0 && {
9027
+ estimated_egress_monthly: Math.round(estimatedEgressMonthly * 100) / 100
9028
+ },
8168
9029
  ...budgetWarnings.length > 0 && { budget_warnings: budgetWarnings }
8169
9030
  };
8170
9031
  }
@@ -8209,84 +9070,15 @@ var CostEngine = class {
8209
9070
  }
8210
9071
  };
8211
9072
 
8212
- // src/currency.ts
8213
- var SUPPORTED_CURRENCIES = [
8214
- "USD",
8215
- "EUR",
8216
- "GBP",
8217
- "JPY",
8218
- "CAD",
8219
- "AUD",
8220
- "INR",
8221
- "BRL"
8222
- ];
8223
- var EXCHANGE_RATES = {
8224
- USD: 1,
8225
- EUR: 0.92,
8226
- GBP: 0.79,
8227
- JPY: 149.5,
8228
- CAD: 1.36,
8229
- AUD: 1.53,
8230
- INR: 83.1,
8231
- BRL: 4.97
8232
- };
8233
- function convertCurrency(amountUsd, targetCurrency) {
8234
- const rate = EXCHANGE_RATES[targetCurrency];
8235
- if (rate === void 0 || rate === 1) {
8236
- return amountUsd;
8237
- }
8238
- return amountUsd * rate;
8239
- }
8240
- function convertLineItem(item, currency) {
8241
- return {
8242
- ...item,
8243
- unit_price: convertCurrency(item.unit_price, currency),
8244
- monthly_cost: convertCurrency(item.monthly_cost, currency)
8245
- };
8246
- }
8247
- function convertCostEstimate(estimate, currency) {
8248
- return {
8249
- ...estimate,
8250
- monthly_cost: convertCurrency(estimate.monthly_cost, currency),
8251
- yearly_cost: convertCurrency(estimate.yearly_cost, currency),
8252
- currency,
8253
- breakdown: estimate.breakdown.map((item) => convertLineItem(item, currency))
8254
- };
8255
- }
8256
- function convertBreakdownCurrency(breakdown, currency) {
8257
- const original_usd = {
8258
- total_monthly: breakdown.total_monthly,
8259
- total_yearly: breakdown.total_yearly
8260
- };
8261
- const convertedByService = {};
8262
- for (const [service, amount] of Object.entries(breakdown.by_service)) {
8263
- convertedByService[service] = convertCurrency(amount, currency);
8264
- }
8265
- return {
8266
- ...breakdown,
8267
- total_monthly: convertCurrency(breakdown.total_monthly, currency),
8268
- total_yearly: convertCurrency(breakdown.total_yearly, currency),
8269
- currency,
8270
- by_service: convertedByService,
8271
- by_resource: breakdown.by_resource.map((est) => convertCostEstimate(est, currency)),
8272
- original_usd
8273
- };
8274
- }
8275
-
8276
9073
  // src/tools/estimate-cost.ts
8277
- var estimateCostSchema = z2.object({
8278
- files: z2.array(
8279
- z2.object({
8280
- path: z2.string().describe("File path"),
8281
- content: z2.string().describe("File content (HCL)")
8282
- })
8283
- ),
8284
- tfvars: z2.string().optional().describe("Contents of terraform.tfvars file"),
8285
- provider: z2.enum(["aws", "azure", "gcp"]).describe("Target cloud provider to estimate costs for"),
8286
- region: z2.string().optional().describe(
9074
+ var estimateCostSchema = z4.object({
9075
+ files: iacFilesSchema,
9076
+ tfvars: tfvarsField,
9077
+ provider: providerEnum.describe("Target cloud provider to estimate costs for"),
9078
+ region: shortStringSchema.optional().describe(
8287
9079
  "Target region for pricing lookup. Defaults to the source region mapped to the target provider."
8288
9080
  ),
8289
- currency: z2.enum(SUPPORTED_CURRENCIES).optional().default("USD").describe("Output currency for cost estimates. Defaults to USD.")
9081
+ currency: currencyField
8290
9082
  });
8291
9083
  async function estimateCost(params, pricingEngine, config) {
8292
9084
  const inventory = await parseTerraform(params.files, params.tfvars);
@@ -8323,12 +9115,13 @@ async function estimateCost(params, pricingEngine, config) {
8323
9115
  return {
8324
9116
  ...convertedRest,
8325
9117
  by_resource: byResource,
8326
- warnings: allWarnings
9118
+ warnings: allWarnings,
9119
+ pricing_metadata: getPricingMetadata(targetProvider)
8327
9120
  };
8328
9121
  }
8329
9122
 
8330
9123
  // src/tools/compare-providers.ts
8331
- import { z as z3 } from "zod";
9124
+ import { z as z5 } from "zod";
8332
9125
 
8333
9126
  // src/calculator/reserved.ts
8334
9127
  var DISCOUNT_RATES = {
@@ -8356,8 +9149,12 @@ var DISCOUNT_RATES = {
8356
9149
  { term: "3yr", payment: "partial_upfront", rate: 0.45 }
8357
9150
  ]
8358
9151
  };
8359
- function calculateReservedPricing(onDemandMonthly, provider) {
8360
- const rates = DISCOUNT_RATES[provider];
9152
+ function calculateReservedPricing(onDemandMonthly, provider, overrides) {
9153
+ const baseRates = DISCOUNT_RATES[provider];
9154
+ const rates = overrides ? baseRates.map((r) => {
9155
+ const override = overrides[r.term];
9156
+ return override !== void 0 ? { ...r, rate: override } : r;
9157
+ }) : baseRates;
8361
9158
  const options = rates.map(({ term, payment, rate }) => {
8362
9159
  const monthly_cost = onDemandMonthly * (1 - rate);
8363
9160
  const monthly_savings = onDemandMonthly - monthly_cost;
@@ -8872,15 +9669,23 @@ function generateJsonReport(comparison, resources, monthlyHours = 730, parseWarn
8872
9669
  return JSON.stringify(output);
8873
9670
  }
8874
9671
 
8875
- // src/reporting/csv-report.ts
8876
- var PROVIDERS = ["aws", "azure", "gcp"];
9672
+ // src/reporting/csv-escape.ts
8877
9673
  function csvEscape(value) {
8878
- const str2 = String(value);
8879
- if (str2.includes(",") || str2.includes("\n") || str2.includes('"')) {
8880
- return `"${str2.replace(/"/g, '""')}"`;
9674
+ if (typeof value === "number") {
9675
+ return Number.isFinite(value) ? String(value) : "";
9676
+ }
9677
+ let str2 = String(value);
9678
+ if (/^[=+\-@\t\r]/.test(str2)) {
9679
+ str2 = "'" + str2;
9680
+ }
9681
+ if (/["\n\r,]/.test(str2)) {
9682
+ str2 = `"${str2.replace(/"/g, '""')}"`;
8881
9683
  }
8882
9684
  return str2;
8883
9685
  }
9686
+
9687
+ // src/reporting/csv-report.ts
9688
+ var PROVIDERS = ["aws", "azure", "gcp"];
8884
9689
  function generateCsvReport(comparison, resources, options = {}) {
8885
9690
  const lines = [];
8886
9691
  const tagKey = options.group_by === "tag" ? options.group_by_tag_key : void 0;
@@ -9041,15 +9846,8 @@ function billingPeriodEnd() {
9041
9846
  const now = /* @__PURE__ */ new Date();
9042
9847
  return new Date(now.getFullYear(), now.getMonth() + 1, 1).toISOString().split("T")[0];
9043
9848
  }
9044
- function csvEscape2(value) {
9045
- const str2 = String(value);
9046
- if (str2.includes(",") || str2.includes("\n") || str2.includes('"')) {
9047
- return `"${str2.replace(/"/g, '""')}"`;
9048
- }
9049
- return str2;
9050
- }
9051
9849
  function buildRow(values) {
9052
- return values.map(csvEscape2).join(",");
9850
+ return values.map(csvEscape).join(",");
9053
9851
  }
9054
9852
  function generateFocusReport(comparison, resources) {
9055
9853
  const lines = [];
@@ -9116,17 +9914,12 @@ function generateFocusReport(comparison, resources) {
9116
9914
  }
9117
9915
 
9118
9916
  // src/tools/compare-providers.ts
9119
- var compareProvidersSchema = z3.object({
9120
- files: z3.array(
9121
- z3.object({
9122
- path: z3.string().describe("File path"),
9123
- content: z3.string().describe("File content (HCL)")
9124
- })
9125
- ),
9126
- tfvars: z3.string().optional().describe("Contents of terraform.tfvars file"),
9127
- format: z3.enum(["markdown", "json", "csv", "focus"]).default("markdown").describe("Output report format"),
9128
- providers: z3.array(z3.enum(["aws", "azure", "gcp"])).default(["aws", "azure", "gcp"]).describe("Cloud providers to include in the comparison"),
9129
- currency: z3.enum(SUPPORTED_CURRENCIES).optional().default("USD").describe("Output currency for cost estimates. Defaults to USD.")
9917
+ var compareProvidersSchema = z5.object({
9918
+ files: iacFilesSchema,
9919
+ tfvars: tfvarsField,
9920
+ format: z5.enum(["markdown", "json", "csv", "focus"]).default("markdown").describe("Output report format"),
9921
+ providers: z5.array(providerEnum).default(["aws", "azure", "gcp"]).describe("Cloud providers to include in the comparison"),
9922
+ currency: currencyField
9130
9923
  });
9131
9924
  async function compareProviders(params, pricingEngine, config) {
9132
9925
  const inventory = await parseTerraform(params.files, params.tfvars);
@@ -9182,11 +9975,13 @@ async function compareProviders(params, pricingEngine, config) {
9182
9975
  break;
9183
9976
  }
9184
9977
  const dedupedWarnings = [.../* @__PURE__ */ new Set([...parseWarnings, ...allWarnings])];
9978
+ const pricingMetadata = Object.fromEntries(providerList.map((p) => [p, getPricingMetadata(p)]));
9185
9979
  if (params.format === "json") {
9186
9980
  return {
9187
9981
  report,
9188
9982
  format: params.format,
9189
- warnings: dedupedWarnings
9983
+ warnings: dedupedWarnings,
9984
+ pricing_metadata: pricingMetadata
9190
9985
  };
9191
9986
  }
9192
9987
  const comparisonSummary = {
@@ -9203,21 +9998,17 @@ async function compareProviders(params, pricingEngine, config) {
9203
9998
  report,
9204
9999
  format: params.format,
9205
10000
  comparison: comparisonSummary,
9206
- warnings: dedupedWarnings
10001
+ warnings: dedupedWarnings,
10002
+ pricing_metadata: pricingMetadata
9207
10003
  };
9208
10004
  }
9209
10005
 
9210
10006
  // src/tools/optimize-cost.ts
9211
- import { z as z4 } from "zod";
9212
- var optimizeCostSchema = z4.object({
9213
- files: z4.array(
9214
- z4.object({
9215
- path: z4.string().describe("File path"),
9216
- content: z4.string().describe("File content (HCL)")
9217
- })
9218
- ),
9219
- tfvars: z4.string().optional().describe("Contents of terraform.tfvars file"),
9220
- providers: z4.array(z4.enum(["aws", "azure", "gcp"])).default(["aws", "azure", "gcp"]).describe("Providers to calculate costs against for cross-provider recommendations")
10007
+ import { z as z6 } from "zod";
10008
+ var optimizeCostSchema = z6.object({
10009
+ files: iacFilesSchema,
10010
+ tfvars: tfvarsField,
10011
+ providers: z6.array(providerEnum).default(["aws", "azure", "gcp"]).describe("Providers to calculate costs against for cross-provider recommendations")
9221
10012
  });
9222
10013
  async function optimizeCost(params, pricingEngine, config) {
9223
10014
  const inventory = await parseTerraform(params.files, params.tfvars);
@@ -9253,24 +10044,23 @@ async function optimizeCost(params, pricingEngine, config) {
9253
10044
  }
9254
10045
 
9255
10046
  // src/tools/what-if.ts
9256
- import { z as z5 } from "zod";
9257
- var whatIfSchema = z5.object({
9258
- files: z5.array(
9259
- z5.object({
9260
- path: z5.string().describe("File path"),
9261
- content: z5.string().describe("File content (HCL)")
9262
- })
9263
- ).describe("Terraform HCL files to analyse"),
9264
- changes: z5.array(
9265
- z5.object({
9266
- resource_id: z5.string().describe("The resource ID to modify (e.g. aws_instance.web)"),
9267
- attribute: z5.string().describe("The attribute name to change (e.g. instance_type, storage_size_gb)"),
9268
- new_value: z5.union([z5.string(), z5.number()]).describe("The new value for the attribute")
10047
+ import { z as z7 } from "zod";
10048
+ var whatIfSchema = z7.object({
10049
+ files: iacFilesSchema.describe("Terraform HCL files to analyse"),
10050
+ changes: z7.array(
10051
+ z7.object({
10052
+ resource_id: shortStringSchema.describe(
10053
+ "The resource ID to modify (e.g. aws_instance.web)"
10054
+ ),
10055
+ attribute: shortStringSchema.describe(
10056
+ "The attribute name to change (e.g. instance_type, storage_size_gb)"
10057
+ ),
10058
+ new_value: z7.union([shortStringSchema, z7.number()]).describe("The new value for the attribute")
9269
10059
  })
9270
- ).describe("List of attribute changes to simulate"),
9271
- provider: z5.enum(["aws", "azure", "gcp"]).optional().describe("Target cloud provider. Defaults to auto-detecting from the files"),
9272
- region: z5.string().optional().describe("Target region for pricing. Defaults to the region detected from provider blocks"),
9273
- tfvars: z5.string().optional().describe("Contents of a terraform.tfvars file for variable resolution")
10060
+ ).max(200, "changes array exceeds 200 entries").describe("List of attribute changes to simulate"),
10061
+ provider: providerEnum.optional().describe("Target cloud provider. Defaults to auto-detecting from the files"),
10062
+ region: shortStringSchema.optional().describe("Target region for pricing. Defaults to the region detected from provider blocks"),
10063
+ tfvars: tfvarsSchema.optional().describe("Contents of a terraform.tfvars file for variable resolution")
9274
10064
  });
9275
10065
  function cloneResources(resources) {
9276
10066
  return JSON.parse(JSON.stringify(resources));
@@ -9366,23 +10156,34 @@ async function whatIf(params, pricingEngine, config) {
9366
10156
  }
9367
10157
 
9368
10158
  export {
9369
- loadConfig,
9370
10159
  setLogLevel,
9371
10160
  logger,
10161
+ loadConfig,
10162
+ SUPPORTED_CURRENCIES,
10163
+ convertCurrency,
10164
+ getExchangeRateInfo,
10165
+ convertBreakdownCurrency,
9372
10166
  PricingCache,
9373
10167
  PricingEngine,
10168
+ sanitizeForMessage,
10169
+ coerceToString,
9374
10170
  str,
9375
10171
  num,
9376
10172
  bool,
10173
+ safeJsonParse,
9377
10174
  parseTerraform,
10175
+ planJsonSchema,
10176
+ stateJsonSchema,
10177
+ shortStringSchema,
10178
+ providerEnum,
10179
+ iacFilesSchema,
10180
+ tfvarsField,
10181
+ currencyField,
9378
10182
  analyzeTerraformSchema,
9379
10183
  analyzeTerraform,
9380
10184
  mapRegion,
9381
10185
  mapInstance,
9382
10186
  CostEngine,
9383
- SUPPORTED_CURRENCIES,
9384
- convertCurrency,
9385
- convertBreakdownCurrency,
9386
10187
  estimateCostSchema,
9387
10188
  estimateCost,
9388
10189
  compareProvidersSchema,
@@ -9392,4 +10193,4 @@ export {
9392
10193
  whatIfSchema,
9393
10194
  whatIf
9394
10195
  };
9395
- //# sourceMappingURL=chunk-E7KOWAMW.js.map
10196
+ //# sourceMappingURL=chunk-CGMU4ETN.js.map