Просмотр исходного кода

feat: port currency conversion backend from upstream PR #3553

Adopts the server-side currency conversion work from
opencost/opencost#3553 (closed, unmerged). Adds ?currency=XYZ support
across /allocation, /allocation/summary, /cloudCost, /customCost/total,
and /customCost/timeseries by threading a currency.Converter through
Accesses and the cloudcost/customcost query services. Consumes the
existing pkg/currency package.

Ported verbatim from adity-a34's branch feature/currency-conversion-backend
(HEAD f3f4706). Subsequent commits fix blockers (missing imports / dead
provider plumbing, missing NAT gateway cost fields, partial-failure
semantics) and add test coverage that the original PR lacked.

Signed-off-by: Warwick Peatey <warwick@automatic.systems>
Assisted-by: Claude Code
Warwick Peatey 1 месяц назад
Родитель
Сommit
8e3ca5e78e

+ 65 - 2
pkg/cloudcost/queryservice.go

@@ -6,8 +6,10 @@ import (
 	"strings"
 
 	"github.com/julienschmidt/httprouter"
+	"github.com/opencost/opencost/core/pkg/log"
 	"github.com/opencost/opencost/core/pkg/opencost"
 	"github.com/opencost/opencost/core/pkg/util/httputil"
+	"github.com/opencost/opencost/pkg/currency"
 	"go.opentelemetry.io/otel"
 )
 
@@ -19,8 +21,9 @@ const (
 
 // QueryService surfaces endpoints for accessing CloudCost data in raw form or for display in views
 type QueryService struct {
-	Querier     Querier
-	ViewQuerier ViewQuerier
+	Querier          Querier
+	ViewQuerier      ViewQuerier
+	CurrencyConverter currency.Converter
 }
 
 func NewQueryService(querier Querier, viewQuerier ViewQuerier) *QueryService {
@@ -61,6 +64,16 @@ func (s *QueryService) GetCloudCostHandler() func(w http.ResponseWriter, r *http
 			return
 		}
 
+		// Extract currency parameter and convert if needed
+		currencyParam := strings.ToUpper(strings.TrimSpace(qp.Get("currency", "USD")))
+		if currencyParam != "USD" && s.CurrencyConverter != nil && resp != nil {
+			err = convertCloudCostSetRange(resp, s.CurrencyConverter, currencyParam)
+			if err != nil {
+				log.Warnf("Currency conversion failed for currency %s: %v", currencyParam, err)
+				// Continue with USD values if conversion fails
+			}
+		}
+
 		_, spanResp := tracer.Start(ctx, "write response")
 		w.Header().Set("Content-Type", "application/json")
 		protocol.WriteData(w, resp)
@@ -203,3 +216,53 @@ func (s *QueryService) GetCloudCostViewTableHandler(tokenHook func(ViewTableRows
 		protocol.WriteResponse(w, resp)
 	}
 }
+
+// convertCloudCostSetRange converts all cloud costs in a range from USD to target currency
+func convertCloudCostSetRange(ccsr *opencost.CloudCostSetRange, converter currency.Converter, targetCurrency string) error {
+	if ccsr == nil || converter == nil || targetCurrency == "USD" {
+		return nil
+	}
+
+	targetCurrency = strings.ToUpper(strings.TrimSpace(targetCurrency))
+
+	// Helper to convert CostMetric (only the Cost field, not KubernetesPercent)
+	convertCostMetric := func(cm *opencost.CostMetric) error {
+		if cm.Cost != 0 {
+			converted, err := converter.Convert(cm.Cost, "USD", targetCurrency)
+			if err != nil {
+				return err
+			}
+			cm.Cost = converted
+		}
+		return nil
+	}
+
+	// Convert all cloud cost sets in the range
+	for _, set := range ccsr.CloudCostSets {
+		if set == nil {
+			continue
+		}
+		for _, cc := range set.CloudCosts {
+			if cc == nil {
+				continue
+			}
+			if err := convertCostMetric(&cc.ListCost); err != nil {
+				return fmt.Errorf("failed to convert ListCost: %w", err)
+			}
+			if err := convertCostMetric(&cc.NetCost); err != nil {
+				return fmt.Errorf("failed to convert NetCost: %w", err)
+			}
+			if err := convertCostMetric(&cc.AmortizedNetCost); err != nil {
+				return fmt.Errorf("failed to convert AmortizedNetCost: %w", err)
+			}
+			if err := convertCostMetric(&cc.InvoicedCost); err != nil {
+				return fmt.Errorf("failed to convert InvoicedCost: %w", err)
+			}
+			if err := convertCostMetric(&cc.AmortizedCost); err != nil {
+				return fmt.Errorf("failed to convert AmortizedCost: %w", err)
+			}
+		}
+	}
+
+	return nil
+}

+ 36 - 3
pkg/cmd/costmodel/costmodel.go

@@ -13,6 +13,7 @@ import (
 	"github.com/opencost/opencost/core/pkg/util/apiutil"
 	"github.com/opencost/opencost/pkg/cloudcost"
 	"github.com/opencost/opencost/pkg/customcost"
+	"github.com/opencost/opencost/pkg/currency"
 	"github.com/prometheus/client_golang/prometheus/promhttp"
 	"github.com/rs/cors"
 
@@ -42,9 +43,38 @@ func Execute(conf *Config) error {
 
 	router := httprouter.New()
 	var a *costmodel.Accesses
+	var cp models.Provider
+	
+	// Initialize currency converter
+	var currencyConverter currency.Converter
+	currencyAPIKey := env.GetCurrencyAPIKey()
+	currencyProvider := env.GetCurrencyProvider()
+
+	if currencyProvider == "exchangerateapi" && currencyAPIKey != "" {
+		config := currency.Config{
+			APIKey:     currencyAPIKey,
+			CacheTTL:   24 * time.Hour,
+			APITimeout: 10 * time.Second,
+		}
+		var err error
+		currencyConverter, err = currency.NewConverter(config)
+		if err != nil {
+			log.Errorf("Failed to initialize currency converter: %v", err)
+			log.Warnf("Currency conversion disabled - will return USD values only")
+		} else {
+			log.Infof("Currency converter initialized successfully")
+		}
+	} else {
+		log.Infof("Currency conversion disabled (provider=%s, hasKey=%v)", 
+			currencyProvider, currencyAPIKey != "")
+	}
 
 	if conf.KubernetesEnabled {
 		a = costmodel.Initialize(router)
+		// Set currency converter in Accesses
+		if a != nil {
+			a.CurrencyConverter = currencyConverter
+		}
 		err := StartExportWorker(context.Background(), a.Model)
 		if err != nil {
 			log.Errorf("couldn't start CSV export worker: %v", err)
@@ -62,13 +92,16 @@ func Execute(conf *Config) error {
 
 	var cloudCostPipelineService *cloudcost.PipelineService
 	if conf.CloudCostEnabled {
-
-		cloudCostPipelineService = costmodel.InitializeCloudCost(router)
+		var providerConfig models.ProviderConfig
+		if cp != nil {
+			providerConfig = provider.ExtractConfigFromProviders(cp)
+		}
+		cloudCostPipelineService = costmodel.InitializeCloudCost(router, providerConfig, currencyConverter)
 	}
 
 	var customCostPipelineService *customcost.PipelineService
 	if conf.CustomCostEnabled {
-		customCostPipelineService = costmodel.InitializeCustomCost(router)
+		customCostPipelineService = costmodel.InitializeCustomCost(router, currencyConverter)
 	}
 
 	// this endpoint is intentionally left out of the "if env.IsCustomCostEnabled()" conditional; in the handler, it is

+ 30 - 0
pkg/costmodel/aggregation.go

@@ -8,6 +8,7 @@ import (
 	"github.com/julienschmidt/httprouter"
 
 	"github.com/opencost/opencost/core/pkg/filter/allocation"
+	"github.com/opencost/opencost/core/pkg/log"
 	"github.com/opencost/opencost/core/pkg/opencost"
 	"github.com/opencost/opencost/core/pkg/util/httputil"
 	"github.com/opencost/opencost/pkg/env"
@@ -163,6 +164,25 @@ func (a *Accesses) ComputeAllocationHandlerSummary(w http.ResponseWriter, r *htt
 	}
 	sasr := opencost.NewSummaryAllocationSetRange(sasl...)
 
+	// Extract currency parameter and convert if needed
+	currency := strings.ToUpper(strings.TrimSpace(qp.Get("currency", "USD")))
+	if currency != "USD" && a.CurrencyConverter != nil {
+		// Convert the underlying AllocationSetRange before creating summary
+		err = ConvertAllocationSetRange(asr, a.CurrencyConverter, currency)
+		if err != nil {
+			log.Warnf("Currency conversion failed for currency %s: %v", currency, err)
+			// Continue with USD values if conversion fails
+		} else {
+			// Recreate summary allocation sets after conversion
+			sasl = []*opencost.SummaryAllocationSet{}
+			for _, as := range asr.Slice() {
+				sas := opencost.NewSummaryAllocationSet(as, nil, nil, false, false)
+				sasl = append(sasl, sas)
+			}
+			sasr = opencost.NewSummaryAllocationSetRange(sasl...)
+		}
+	}
+
 	WriteData(w, sasr, nil)
 }
 
@@ -242,5 +262,15 @@ func (a *Accesses) ComputeAllocationHandler(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
+	// Extract currency parameter and convert if needed
+	currency := strings.ToUpper(strings.TrimSpace(qp.Get("currency", "USD")))
+	if currency != "USD" && a.CurrencyConverter != nil {
+		err = ConvertAllocationSetRange(asr, a.CurrencyConverter, currency)
+		if err != nil {
+			log.Warnf("Currency conversion failed for currency %s: %v", currency, err)
+			// Continue with USD values if conversion fails
+		}
+	}
+
 	WriteData(w, asr, nil)
 }

+ 188 - 0
pkg/costmodel/currency_helper.go

@@ -0,0 +1,188 @@
+package costmodel
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/opencost/opencost/core/pkg/opencost"
+	"github.com/opencost/opencost/pkg/currency"
+)
+
+// ConvertAllocation converts all cost fields in an Allocation from USD to target currency
+func ConvertAllocation(alloc *opencost.Allocation, converter currency.Converter, targetCurrency string) error {
+	if alloc == nil || converter == nil || targetCurrency == "USD" {
+		return nil
+	}
+
+	targetCurrency = strings.ToUpper(strings.TrimSpace(targetCurrency))
+
+	// List of all float64 cost fields to convert
+	costFields := []*float64{
+		&alloc.CPUCost,
+		&alloc.CPUCostAdjustment,
+		&alloc.CPUCostIdle,
+		&alloc.GPUCost,
+		&alloc.GPUCostAdjustment,
+		&alloc.GPUCostIdle,
+		&alloc.NetworkCost,
+		&alloc.NetworkCrossZoneCost,
+		&alloc.NetworkCrossRegionCost,
+		&alloc.NetworkInternetCost,
+		&alloc.NetworkCostAdjustment,
+		&alloc.LoadBalancerCost,
+		&alloc.LoadBalancerCostAdjustment,
+		&alloc.PVCostAdjustment,
+		&alloc.RAMCost,
+		&alloc.RAMCostAdjustment,
+		&alloc.RAMCostIdle,
+		&alloc.SharedCost,
+		&alloc.ExternalCost,
+		&alloc.UnmountedPVCost,
+	}
+
+	// Convert each cost field
+	for _, costPtr := range costFields {
+		if *costPtr != 0 {
+			converted, err := converter.Convert(*costPtr, "USD", targetCurrency)
+			if err != nil {
+				return fmt.Errorf("failed to convert cost: %w", err)
+			}
+			*costPtr = converted
+		}
+	}
+
+	// Convert PV costs (nested structure)
+	for pvKey, pv := range alloc.PVs {
+		if pv.Cost != 0 {
+			converted, err := converter.Convert(pv.Cost, "USD", targetCurrency)
+			if err != nil {
+				return fmt.Errorf("failed to convert PV cost: %w", err)
+			}
+			pv.Cost = converted
+			alloc.PVs[pvKey] = pv
+		}
+	}
+
+	// Convert LoadBalancer costs (nested structure)
+	for lbKey, lb := range alloc.LoadBalancers {
+		if lb.Cost != 0 {
+			converted, err := converter.Convert(lb.Cost, "USD", targetCurrency)
+			if err != nil {
+				return fmt.Errorf("failed to convert LB cost: %w", err)
+			}
+			lb.Cost = converted
+			alloc.LoadBalancers[lbKey] = lb
+		}
+	}
+
+	return nil
+}
+
+// ConvertAllocationSet converts all allocations in a set
+func ConvertAllocationSet(set *opencost.AllocationSet, converter currency.Converter, targetCurrency string) error {
+	if set == nil || converter == nil || targetCurrency == "USD" {
+		return nil
+	}
+
+	for _, alloc := range set.Allocations {
+		if err := ConvertAllocation(alloc, converter, targetCurrency); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// ConvertAllocationSetRange converts all sets in a range
+func ConvertAllocationSetRange(asr *opencost.AllocationSetRange, converter currency.Converter, targetCurrency string) error {
+	if asr == nil || converter == nil || targetCurrency == "USD" {
+		return nil
+	}
+
+	for _, set := range asr.Allocations {
+		if err := ConvertAllocationSet(set, converter, targetCurrency); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// ConvertCloudCost converts all cost metrics in a CloudCost
+func ConvertCloudCost(cc *opencost.CloudCost, converter currency.Converter, targetCurrency string) error {
+	if cc == nil || converter == nil || targetCurrency == "USD" {
+		return nil
+	}
+
+	targetCurrency = strings.ToUpper(strings.TrimSpace(targetCurrency))
+
+	// Helper to convert CostMetric (only the Cost field, not KubernetesPercent)
+	convertCostMetric := func(cm *opencost.CostMetric) error {
+		if cm.Cost != 0 {
+			converted, err := converter.Convert(cm.Cost, "USD", targetCurrency)
+			if err != nil {
+				return err
+			}
+			cm.Cost = converted
+		}
+		return nil
+	}
+
+	// Convert all cost metrics
+	if err := convertCostMetric(&cc.ListCost); err != nil {
+		return fmt.Errorf("failed to convert ListCost: %w", err)
+	}
+	if err := convertCostMetric(&cc.NetCost); err != nil {
+		return fmt.Errorf("failed to convert NetCost: %w", err)
+	}
+	if err := convertCostMetric(&cc.AmortizedNetCost); err != nil {
+		return fmt.Errorf("failed to convert AmortizedNetCost: %w", err)
+	}
+	if err := convertCostMetric(&cc.InvoicedCost); err != nil {
+		return fmt.Errorf("failed to convert InvoicedCost: %w", err)
+	}
+	if err := convertCostMetric(&cc.AmortizedCost); err != nil {
+		return fmt.Errorf("failed to convert AmortizedCost: %w", err)
+	}
+
+	return nil
+}
+
+// ConvertCloudCostSet converts all cloud costs in a set
+func ConvertCloudCostSet(set *opencost.CloudCostSet, converter currency.Converter, targetCurrency string) error {
+	if set == nil || converter == nil || targetCurrency == "USD" {
+		return nil
+	}
+
+	for _, cc := range set.CloudCosts {
+		if cc == nil {
+			continue
+		}
+		if err := ConvertCloudCost(cc, converter, targetCurrency); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// ConvertCloudCostSetRange converts all sets in a range
+func ConvertCloudCostSetRange(ccsr *opencost.CloudCostSetRange, converter currency.Converter, targetCurrency string) error {
+	if ccsr == nil || converter == nil || targetCurrency == "USD" {
+		return nil
+	}
+
+	targetCurrency = strings.ToUpper(strings.TrimSpace(targetCurrency))
+
+	// Convert all cloud cost sets in the range
+	for _, set := range ccsr.CloudCostSets {
+		if set == nil {
+			continue
+		}
+		if err := ConvertCloudCostSet(set, converter, targetCurrency); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}

+ 7 - 2
pkg/costmodel/router.go

@@ -40,6 +40,7 @@ import (
 	"github.com/opencost/opencost/modules/prometheus-source/pkg/prom"
 	"github.com/opencost/opencost/pkg/cloud/models"
 	clusterc "github.com/opencost/opencost/pkg/clustercache"
+	"github.com/opencost/opencost/pkg/currency"
 	"github.com/opencost/opencost/pkg/env"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 
@@ -79,6 +80,8 @@ type Accesses struct {
 	// settings will be published in a pub/sub model
 	settingsSubscribers map[string][]chan string
 	settingsMutex       sync.Mutex
+	// CurrencyConverter handles currency conversion for cost values
+	CurrencyConverter currency.Converter
 }
 
 func filterFields(fields string, data map[string]*CostData) map[string]CostData {
@@ -607,7 +610,7 @@ func GetDefaultCollectorStorage() storage.Storage {
 }
 
 // InitializeCloudCost Initializes Cloud Cost pipeline and querier and registers endpoints
-func InitializeCloudCost(router *httprouter.Router) *cloudcost.PipelineService {
+func InitializeCloudCost(router *httprouter.Router, providerConfig models.ProviderConfig, currencyConverter currency.Converter) *cloudcost.PipelineService {
 	log.Debugf("Cloud Cost config path: %s", env.GetCloudCostConfigPath())
 	cloudConfigController := cloudconfig.NewMemoryController(nil)
 
@@ -615,6 +618,7 @@ func InitializeCloudCost(router *httprouter.Router) *cloudcost.PipelineService {
 	cloudCostPipelineService := cloudcost.NewPipelineService(repo, cloudConfigController, cloudcost.DefaultIngestorConfiguration())
 	repoQuerier := cloudcost.NewRepositoryQuerier(repo)
 	cloudCostQueryService := cloudcost.NewQueryService(repoQuerier, repoQuerier)
+	cloudCostQueryService.CurrencyConverter = currencyConverter
 
 	router.GET("/cloudCost", cloudCostQueryService.GetCloudCostHandler())
 	router.GET("/cloudCost/view/graph", cloudCostQueryService.GetCloudCostViewGraphHandler())
@@ -632,7 +636,7 @@ func InitializeCloudCost(router *httprouter.Router) *cloudcost.PipelineService {
 	return cloudCostPipelineService
 }
 
-func InitializeCustomCost(router *httprouter.Router) *customcost.PipelineService {
+func InitializeCustomCost(router *httprouter.Router, currencyConverter currency.Converter) *customcost.PipelineService {
 	hourlyRepo := customcost.NewMemoryRepository()
 	dailyRepo := customcost.NewMemoryRepository()
 	ingConfig := customcost.DefaultIngestorConfiguration()
@@ -645,6 +649,7 @@ func InitializeCustomCost(router *httprouter.Router) *customcost.PipelineService
 
 	customCostQuerier := customcost.NewRepositoryQuerier(hourlyRepo, dailyRepo, ingConfig.HourlyDuration, ingConfig.DailyDuration)
 	customCostQueryService := customcost.NewQueryService(customCostQuerier)
+	customCostQueryService.CurrencyConverter = currencyConverter
 
 	router.GET("/customCost/total", customCostQueryService.GetCustomCostTotalHandler())
 	router.GET("/customCost/timeseries", customCostQueryService.GetCustomCostTimeseriesHandler())

+ 84 - 1
pkg/customcost/queryservice.go

@@ -3,16 +3,20 @@ package customcost
 import (
 	"fmt"
 	"net/http"
+	"strings"
 
 	"github.com/julienschmidt/httprouter"
+	"github.com/opencost/opencost/core/pkg/log"
 	"github.com/opencost/opencost/core/pkg/util/httputil"
+	"github.com/opencost/opencost/pkg/currency"
 	"go.opentelemetry.io/otel"
 )
 
 const tracerName = "github.com/opencost/opencost/pkg/customcost"
 
 type QueryService struct {
-	Querier Querier
+	Querier          Querier
+	CurrencyConverter currency.Converter
 }
 
 func NewQueryService(querier Querier) *QueryService {
@@ -51,6 +55,16 @@ func (qs *QueryService) GetCustomCostTotalHandler() func(w http.ResponseWriter,
 			return
 		}
 
+		// Extract currency parameter and convert if needed
+		currencyParam := strings.ToUpper(strings.TrimSpace(qp.Get("currency", "USD")))
+		if currencyParam != "USD" && qs.CurrencyConverter != nil && resp != nil {
+			err = convertCustomCostResponse(resp, qs.CurrencyConverter, currencyParam)
+			if err != nil {
+				log.Warnf("Currency conversion failed for currency %s: %v", currencyParam, err)
+				// Continue with USD values if conversion fails
+			}
+		}
+
 		_, spanResp := tracer.Start(ctx, "write response")
 		w.Header().Set("Content-Type", "application/json")
 		protocol.WriteData(w, resp)
@@ -88,9 +102,78 @@ func (qs *QueryService) GetCustomCostTimeseriesHandler() func(w http.ResponseWri
 			return
 		}
 
+		// Extract currency parameter and convert if needed
+		currencyParam := strings.ToUpper(strings.TrimSpace(qp.Get("currency", "USD")))
+		if currencyParam != "USD" && qs.CurrencyConverter != nil && resp != nil {
+			err = convertCustomCostTimeseriesResponse(resp, qs.CurrencyConverter, currencyParam)
+			if err != nil {
+				log.Warnf("Currency conversion failed for currency %s: %v", currencyParam, err)
+				// Continue with USD values if conversion fails
+			}
+		}
+
 		_, spanResp := tracer.Start(ctx, "write response")
 		w.Header().Set("Content-Type", "application/json")
 		protocol.WriteData(w, resp)
 		spanResp.End()
 	}
 }
+
+// convertCustomCostResponse converts all cost values in a CostResponse from USD to target currency
+func convertCustomCostResponse(resp *CostResponse, converter currency.Converter, targetCurrency string) error {
+	if resp == nil || converter == nil || targetCurrency == "USD" {
+		return nil
+	}
+
+	targetCurrency = strings.ToUpper(strings.TrimSpace(targetCurrency))
+
+	// Convert TotalCost
+	if resp.TotalCost != 0 {
+		converted, err := converter.Convert(float64(resp.TotalCost), "USD", targetCurrency)
+		if err != nil {
+			return fmt.Errorf("failed to convert TotalCost: %w", err)
+		}
+		resp.TotalCost = float32(converted)
+	}
+
+	// Convert all CustomCost items
+	for _, cc := range resp.CustomCosts {
+		if cc == nil {
+			continue
+		}
+		// Convert Cost
+		if cc.Cost != 0 {
+			converted, err := converter.Convert(float64(cc.Cost), "USD", targetCurrency)
+			if err != nil {
+				return fmt.Errorf("failed to convert CustomCost.Cost: %w", err)
+			}
+			cc.Cost = float32(converted)
+		}
+		// Convert ListUnitPrice
+		if cc.ListUnitPrice != 0 {
+			converted, err := converter.Convert(float64(cc.ListUnitPrice), "USD", targetCurrency)
+			if err != nil {
+				return fmt.Errorf("failed to convert CustomCost.ListUnitPrice: %w", err)
+			}
+			cc.ListUnitPrice = float32(converted)
+		}
+	}
+
+	return nil
+}
+
+// convertCustomCostTimeseriesResponse converts all cost values in a CostTimeseriesResponse
+func convertCustomCostTimeseriesResponse(resp *CostTimeseriesResponse, converter currency.Converter, targetCurrency string) error {
+	if resp == nil || converter == nil || targetCurrency == "USD" {
+		return nil
+	}
+
+	// Convert each CostResponse in the timeseries
+	for _, costResp := range resp.Timeseries {
+		if err := convertCustomCostResponse(costResp, converter, targetCurrency); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}

+ 14 - 0
pkg/env/opencost.go

@@ -17,6 +17,8 @@ const (
 const (
 	UTCOffsetEnvVar              = "UTC_OFFSET"
 	MCPQueryTimeoutSecondsEnvVar = "MCP_QUERY_TIMEOUT_SECONDS"
+	CurrencyProviderEnvVar       = "CURRENCY_PROVIDER"
+	CurrencyAPIKeyEnvVar         = "CURRENCY_API_KEY"
 )
 
 func GetOpencostAPIPort() int {
@@ -54,3 +56,15 @@ func GetMCPQueryTimeout() time.Duration {
 	}
 	return time.Duration(seconds) * time.Second
 }
+
+// GetCurrencyProvider returns the currency provider name from environment variable.
+// Default is "default" which disables currency conversion.
+func GetCurrencyProvider() string {
+	return env.Get(CurrencyProviderEnvVar, "default")
+}
+
+// GetCurrencyAPIKey returns the currency API key from environment variable.
+// Returns empty string if not set.
+func GetCurrencyAPIKey() string {
+	return env.Get(CurrencyAPIKeyEnvVar, "")
+}