Contributing to Infra Cost Model¶
Adding a SaaS vendor¶
To add a SaaS vendor (for example, Linear, Datadog, or Vercel):
- Copy
infra_cost_model/vendors/_template/toinfra_cost_model/vendors/<your-vendor>/. The directory name is the vendor id: a lowercase id with hyphens. A cost model node setsproviderto this id. - Edit
vendor.yaml: setidto the directory name, and setdisplay_name,homepage, andpricing_page. - Edit
prices.yaml: add normalized price rows as described below. - Run
python3 -m infra_cost_model.cli validate <file>for every cost model example you add or change. - Run
python3 -m pytest -qto verify that the vendor loads correctly. - Open a pull request. A vendor-only contribution should touch only
infra_cost_model/vendors/<your-vendor>/.
Two vendor-only pull requests do not conflict because each changes prices in its own directory.
Subscriptions, per-unit prices and free allowances¶
Put these in price rows. Engine versions before 0.3.0 had three SaaS pricing shapes for them. Version 0.3.0 removed them (#246), and validate rejects a model that sets shape to one of them. To move such a metric to price rows, first add the rows to the vendor's prices.yaml. In the model, set the node's provider to the vendor id and its service to the rows' service. Rename the metric to the row's usage_metric, then delete shape and its parameters from the metric.
flat_subscriptionwithrate: 99becomes a row withprice_usd: 99and afixed: truemetric whose value is the number of subscriptions.per_unit_flatwithrate: 125becomes a row withprice_usd: 125and afixed: truemetric whose value is the unit count.free_tierwithfree: 1000000andoverage: 0.0025becomes two rows:price_usd: 0fromstart_usage_amount: 0toend_usage_amount: 1000000, thenprice_usd: 0.0025fromstart_usage_amount: 1000000. Thetiersparameter becomes more rows.
examples/saas-subscription-api.yaml prices WorkOS and Datadog this way. Models can still set shape: transactional. It charges a percentage of each transaction's value plus fixed fees. A price row can't state that charge, because it depends on the value of a transaction.
Price row reference¶
Each entry in prices.yaml represents one flat price or one tier. Fields use the same snake-case names as the Price dataclass:
vendor(required): Canonical provider identity used by cost model nodes and catalog queries. It must equal the directory name and theidin the directory'svendor.yaml. Use the provider's stable vendor identity, not a product, plan, display name, or reseller name. GitHub Copilot rows usegithub, ininfra_cost_model/vendors/github/. The loader stops with an error when a row'svendordiffers from its directory's id, or when a directory has no rows.service(required): Stable service or product identifier within the vendor catalog.region(required): Pricing region. Useglobalonly when the vendor publishes one location-independent price.product_family(optional): Provider product-family classification when needed to distinguish otherwise similar offers.attributes(optional): Provider-specific dimensions that identify the priced offer. Use an empty mapping when there are none.usage_metric(required): Canonical metric consumed by catalog queries and the cost model's usage derivation layer.unit(required): Unit to whichprice_usdapplies, such asrequest,GB, ormonth.price_usd(required): Price in United States dollars perunitwithin this row's tier. A zero price represents a free tier.start_usage_amount(optional): Inclusive lower tier boundary. Use0for the first bounded tier. For a free tier, pairstart_usage_amount: 0with a positiveend_usage_amountandprice_usd: 0; the paid overage row starts at that same end boundary.end_usage_amount(optional): Exclusive upper tier boundary. Omit it for the final open-ended overage tier. Adjacent tiers should share boundaries without gaps or overlaps.purchase_option(optional): Provider purchase or commitment option when it distinguishes prices for the same metric.per(optional): Name of the cost model parameter that scales this row's tier boundaries. For example,per: seatsmultiplies bothstart_usage_amountandend_usage_amountby the resolvedseatsvalue. It does not multiplyprice_usd; quantities above the scaled boundary are priced normally.block_size(optional): A positive number ofunits in one billing block. When set,price_usdis the price of one block, not of one unit, and the quantity in this row's band (the part of the month's quantity betweenstart_usage_amountandend_usage_amount) rounds up to a whole number of blocks: a partly used block costs the whole block, and a band with no usage costs nothing. Exact multiples don't round up. A vendor that bills $2,500 for each 1,000,000 monthly active users above the free million states its overage row withprice_usd: 2500,start_usage_amount: 1000000andblock_size: 1000000, and 1,200,000 users cost $2,500. Blocks round up once on the month's total, so nodes that pool one metric share the whole blocks of the pooled total, and the cost is the same on every time basis. Rows without the field price each unit (#369). Live sources never set it.effective_date(optional): Date from which the published price applies, inYYYY-MM-DDform. Record the provider's effective date rather than the date the row was added. Update it whenever the canonical price changes.source(optional): Provenance for the price, preferably the provider's authoritative pricing or billing documentation URL. Every manually maintained price should be traceable to such a source; do not use an aggregator when first-party documentation exists.fetched_at(optional): Timestamp when an automatically fetched price was retrieved. This is cache metadata and is normally omitted from hand-maintained vendor files.
State start_usage_amount and end_usage_amount as a quantity per month, the way providers publish their allowances. The engine derives usage per second, so it scales a month of usage against the boundaries and then converts the cost to the output time basis (#287).
The boundaries apply once to the whole account. The engine adds up the monthly quantity of each metric across all nodes and workflows that share a provider, service and region, prices that total, and splits the cost across the nodes by quantity (#294). A pair of queues with 1,000,000 requests each shares one free allowance of 1,000,000 requests.
Some providers give a free allowance once to the whole account, across all regions. AWS does this for the first 100 GB of data transfer out, for the Lambda, SQS, SNS and KMS free requests, and for the CloudWatch free metrics, alarm metrics and log data. Azure does this for the free grant of the Functions consumption plan. ACCOUNT_WIDE_FREE_TIERS in infra_cost_model/pricing/free_tiers.py lists these metrics by vendor, service and usage_metric. For a listed metric, the engine applies the allowance once to the total of all regions and gives each region a part in proportion to its quantity. Each region pays its own rate for the rest (#336). A metric missing from the list gets one allowance per region. The list, not a price row field, marks an allowance as account-wide, so rows from the seed file, the vendor files and live sources get the same treatment.
A few providers give one free allowance to several metrics of a service. AWS gives 1,000,000 free SQS requests a month to standard and FIFO queues together, and 10,000,000 free CloudFront requests to HTTP and HTTPS together. SHARED_FREE_ALLOWANCES in the same file lists each such group with its vendor, service, metrics, allowance and unit. For a listed metric, the engine applies the group's allowance once to the total of all its metrics in all regions, and gives each pool a part in proportion to its quantity. Each pool pays its own metric's rate in its own region for the rest, from the first paid tier (#338). The table's allowance replaces the free tier in the metrics' rows. The seed file keeps a free row for each SQS metric, because it states the right price for a direct catalog query of one queue type.
A global service bills the account's use in every region together, at one price. AWS bills Route 53 hosted zones this way: $0.50 a month for each of the first 25 zones in the account, then $0.10. Standard queries to public hosted zones work the same way: $0.40 a million for the first billion queries a month, then $0.20 (#384). GLOBAL_METRICS in infra_cost_model/pricing/global_services.py lists such metrics by vendor, service and usage_metric. For a listed metric used in two or more regions, the engine prices the total of all regions once and splits the cost across the regions by quantity (#378). The rows under the region global price the total, or else the us-east-1 rows, or else the rows of one of the nodes' regions. A node in a region with no rows for a listed metric gets its price from the same rows. Add a metric only when the provider's pricing page confirms one price and one set of tiers for the whole account.
A resource that has no AWS region, such as a CloudFront distribution or a WAF web ACL with the scope CLOUDFRONT, gets the region global. The seed file prices it with rows under global. The Infracost Cloud Pricing API keeps these prices in its global catalog, with the usagetype prefix Global-. A descriptor with global_scope stores them under global, and sync-pricing syncs global after the AWS regions (#385). A CloudFront descriptor also sets global_only, so sync-pricing reads its products for global and skips them in each AWS region (#470).
A Cloud Storage bucket's location is its catalog region, and a location that is not a GCP region syncs like one (#397). us, eu and asia are the multi-regions, and nam4, eur4, eur5, eur7, eur8 and asia1 are the six predefined dual-regions that Cloud Storage defines; another location has no rows. A configurable dual-region shares its location code with a multi-region, so a bucket's location alone can't tell the two apart and is priced as that multi-region. GCS_LOCATIONS in infra_cost_model/pricing/gcp_locations.py lists the codes that sync-pricing adds to the GCP regions, and only the Cloud Storage descriptors sync them. The API keeps multi-region storage under us and asia, a dual-region product under every GCP region beside the regional one, and the operations of both kinds in its global catalog. GCS_LOCATION_QUERY names the region a location's price is read from: GCP gives the three multi-regions one price and every dual-region the same one, so eu reads the us product and a dual-region code reads one region's dual-region product.
The Infracost Cloud Pricing API states each paid price from 0 and leaves out the free allowances, which AWS publishes as separate "Global-" products. FREE_ALLOWANCES in the same file gives the monthly allowance of each metric that has a $0 tier in the seed file. When sync-pricing stores the live rows of a listed metric, it adds a $0 tier up to the allowance and starts the paid tiers there, so a live catalog prices the same usage as the seed catalog (#356). When you add a free tier to the seed file, add its allowance to FREE_ALLOWANCES as well. A test checks that the two agree.
Some allowances need more than a number (#372, #373). Firestore gives a quota each day, so its entry is a day's quota times 30.4375, the days in an average month. GCP gives the Cloud Run free tier as a sum of money at Tier 1 prices. SPEND_BASED_FREE_TIERS states that price, and the sync gives a Tier 2 region fewer free units. Cloud Storage gives its free storage, operations and egress in three US regions only, so FREE_ALLOWANCE_REGIONS lists them, and the sync drops the product's $0 tier in the other regions (#390). Cloud Storage bills egress from every region on one SKU, so PRICE_POOLS prices all regions together, and the engine applies the free 100 GiB once, to the egress from the three regions (#404).
When the provider's pricing page states other tier bounds than the Infracost API, TIER_BOUND_OVERRIDES in infra_cost_model/pricing/sources/infracost.py gives both sets of bounds, the page, and the date you checked it. The sync then stores the page's bounds. It keeps the Infracost bounds once they no longer match the entry (#391).
When the Infracost API has no price for an Azure meter in a region, the sync reads the public Azure Retail Prices API, which Infracost copies (#376). A descriptor with azure_retail always reads that API. Its rows have the source azure-retail, and they replace seed rows the way Infracost rows do. Azure bills internet egress by zone. When a region lacks the egress meter in that API too, AZURE_EGRESS_METER_FALLBACK names a region of the same zone to read the meter from (#392).
Some synced GCP regions hold no 1st gen Cloud Run functions row, so FUNCTIONS_GEN1_UNPRICED_REGIONS in infra_cost_model/pricing/gcp_locations.py lists them and the 1st gen handler warns at extraction, naming the 2nd gen resource that is priced at Cloud Run rates (#400). The warning states what the catalog lacks, not what the provider sells; the set's membership is unverified, so confirm it against the API with a working credential before trusting it.
A Blob Storage account's settings select its product (#375, #398). The account kind picks one of three: a general-purpose v2 account bills General Block Blob v2, a general-purpose v1 account (the original Storage kind) Blob Storage, and a Premium account Premium Block Blob, which has no access tier of its own. The access tier and the redundancy then pick the SKU. Azure shares a meter between the SKUs of a tier — Hot GRS bills reads on the meter Hot LRS reads on, and the zone-redundant SKUs on the ZRS one — so a metric names its own SKU and the descriptor names the meter.
The cool tiers bill two more quantities. dataRetrievalGb is the GB read back out of a Cool, Cold or Archive blob. earlyDeleteGb is the GB deleted before the tier's minimum retention, which Azure charges at the tier's storage price for the whole window: 30 days for Cool, 90 for Cold and 180 for Archive (https://azure.microsoft.com/en-us/pricing/details/storage/blobs/). Azure publishes one Early Delete meter per SKU, priced at a month of the tier's storage per GB, and bills it for the days left in the window. A Cool blob deleted after 21 days is charged for 9 days, and an Archive one deleted after 45 days for 135 (https://learn.microsoft.com/en-us/azure/storage/blobs/access-tiers-overview). The metric counts the GB deleted early, and the node's earlyDeleteDaysStored setting says how many days they stayed in the tier, 0 by default. The handler bills each GB for the days left over 30, so with the default of 0 a GB pays the whole window: one month in Cool, three in Cold and six in Archive. A value that isn't a number of at least 0 stops the run.
Azure publishes no write meter at all for Hot and Cool RA-GZRS (checked in the Azure Retail Prices API on 2026-10-02, product General Block Blob v2, skuName Hot RA-GZRS and Cool RA-GZRS), while billing Hot RA-GRS writes on the meter Hot GRS writes use. Those writes are not charged separately, so the handler prices none of them and does not warn that it cannot. Add a meter of their own only when Azure publishes one.
An App Service plan bills one instance-hour of its SKU, and Azure gives each SKU family its own product: one for Linux and one for Windows, one more for the Windows container (Xenon) plans. The seed covers Basic, Standard, Premium v2, Premium v3, Premium v4, the classic Premium (P1 to P4), the Windows container (PC2 to PC4) and Isolated v1, v2 and v4, with the product, SKU and meter each one names in the Azure Retail Prices API (checked 2026-10-02, eastus, pay-as-you-go). The classic Premium plans are Windows-only and the container plans run Windows containers, so a plan of another kind on those SKUs has no rows and the handler warns (#407).
A Flex Consumption Function App bills its executions and their time on the on-demand meters, at $0.0000004 an execution and $0.000026 a GB-second in eastus. An app with always-ready instances bills three other meters, each with no free allowance: executions at $0.0000004, execution time at $0.000016 a GB-second, and the baseline at $0.000004 a GB-second (same source, checked 2026-10-02). The alwaysReadyInstances setting counts the always-ready instances of the app, which Azure states as siteConfig.alwaysReady; the app then bills its executions on the always-ready meters. Which executions run on those instances and which scale out to on-demand instances is not modelled, so an app that mixes them bills them all at the always-ready rates. The alwaysReadyGBHours usage metric is the capacity those instances hold ready, and bills the baseline:
baseline_GBs = alwaysReadyInstances x instanceGiB x hours x 3600execution_GBs = invocations x instanceGiB x max(avgDurationMs, 100) / 1000 / concurrency
instanceGiB is the memory of the Flex instance the app's memoryMb selects: 0.5, 2 or 4, and hours the hours an instance stays ready in the period. An instance bills its time once for the executions it runs together, so the concurrency setting divides the execution GB-seconds by the executions one instance runs at once; without it each execution bills its own duration, which overstates the cost of a concurrent app. Without alwaysReadyInstances and concurrency the app bills exactly what it billed before (#407).
A vendor directory and its vendor.yaml manifest define the canonical vendor identity. Examples, provider registration and price rows use that identity. A few rows for a cloud provider's own services, such as Amazon Cognito, come from outside the live catalog. They go in the directory for that provider (aws, azure or gcp). prices.yaml is the canonical price data; nearby research notes may explain the model and cite sources but must not become a second price schedule.
Per-metric cost bookkeeping¶
compute --format json reports what each usage metric of each node cost. Every number in the snapshot is one the engine computed. A node's printed metrics reproduce its printed total within the last digit each number keeps (six decimals, or six digits from the first nonzero one below 1), and the model total is the one the table prints. A new pricing path must record every cost it books to the usage metric behind it, so that holds. After it knows a cost, it records the metric with _record_metric (the internal cost per second for a usage-driven metric, per month for a fixed one, plus where the price came from), or calls _record_unpriced when nothing priced it. A price the engine pools across nodes, such as a shared free allowance or a global service (#294, #378), names the metric its charge belongs to on the _CatalogCharge, so the pool prices the metric that incurred the charge. tests/test_compute_snapshot.py checks that a node's metrics reproduce its total for every bundled example on every time basis.
Development¶
- Run tests:
python3 -m pytest -q - Validate a model:
python3 -m infra_cost_model.cli validate <file> - Build the documentation site:
pip install -e ".[docs]", thenmkdocs build --strict(ormkdocs serveto preview it).docs/hooks.pybuilds every page from the Markdown files at the repository root, the example models, the vendor pricing notes, and the command parser, so a change to any of them changes the site. Links between the root Markdown files use the file names, such asDESIGN_PRINCIPLES.md, so they work on GitHub and on the site. After a merge intomain,.github/workflows/docs.ymldeploys the site to GitHub Pages. - Required checks: a PR merges into
mainonly aftertest (3.11),test (3.12),test (3.13),ts,vendor-check, andwheel-installpass. A repository ruleset in the GitHub settings sets this list. - Core documentation:
- DESIGN_PRINCIPLES.md
- UBIQUITOUS_LANGUAGE.md
Releasing¶
Publishing a GitHub release starts .github/workflows/publish.yml, which builds the package and uploads it to PyPI.
- Set the new version in
infra_cost_model/__init__.py(__version__) and insdk/ts/package.jsonin the same PR.tests/test_sdk_version.pyfails when the two differ. - After that PR merges, create a GitHub release on
mainwith the tagvX.Y.Z, whereX.Y.Zmatches__version__. If they differ, the workflow stops before it builds anything.
PyPI accepts the upload through trusted publishing, so the repository doesn't store a PyPI token. The project's trusted publisher on pypi.org names owner elecnix, repository infra-cost-model, workflow publish.yml and environment pypi, and the pypi environment exists in the repository settings, where you can add required reviewers to approve each upload. If the publish job fails with invalid-publisher, check that those four values still match on pypi.org, then rerun the failed job of the release's workflow run.