From f97bc4f8b751fa6aa5f82eeb30476e7605406ede Mon Sep 17 00:00:00 2001 From: x7even <38901965+x7even@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:01:53 +0000 Subject: [PATCH 1/9] feat(bom): add raw-SKU line items to estimate_bom and compare_bom_regions (RC3-004, #31) Route CUR-style usage-type/SKU strings through processBOMItems (shared by estimate_bom and compare_bom_regions) via the same AWS SKU resolver get_price_by_sku already uses, so both tools accept a raw-SKU item alongside PricingSpec dicts. Scoped to raw-SKU support only; weighting and a providers filter are deferred. --- opencloudcosts-go/internal/server/server.go | 12 +- opencloudcosts-go/internal/tools/bom.go | 200 ++++++++++- opencloudcosts-go/internal/tools/bom_test.go | 332 ++++++++++++++++++ .../internal/tools/compare_bom_regions.go | 26 +- .../tools/compare_bom_regions_test.go | 102 ++++++ .../internal/tools/sku_lookup.go | 68 +++- .../schemas/tools-output-snapshot.json | 6 + opencloudcosts-go/schemas/tools-snapshot.json | 40 ++- 8 files changed, 743 insertions(+), 43 deletions(-) diff --git a/opencloudcosts-go/internal/server/server.go b/opencloudcosts-go/internal/server/server.go index ddfa415..d829dad 100644 --- a/opencloudcosts-go/internal/server/server.go +++ b/opencloudcosts-go/internal/server/server.go @@ -471,6 +471,7 @@ const ( schemaCompareBOMRegions = `{ "properties": { "items": { + "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, AWS-only) — see tool description.", "items": { "additionalProperties": true, "type": "object" @@ -582,6 +583,7 @@ const ( schemaEstimateBOM = `{ "properties": { "items": { + "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, AWS-only) — see tool description.", "items": { "additionalProperties": true, "type": "object" @@ -2181,6 +2183,9 @@ const ( }, "fallback_note": { "type": "string" + }, + "sku": { + "type": "string" } } } @@ -2433,6 +2438,9 @@ const ( }, "fallback_note": { "type": "string" + }, + "sku": { + "type": "string" } } } @@ -3007,7 +3015,7 @@ const ( descDescribeCatalog = "\n Discover what each provider supports and how to call get_price.\n\n - No args → full support matrix across all configured providers.\n - provider only → all domains/services for that provider.\n - provider + domain [+ service] → targeted guidance with required_fields,\n supported_terms, filter_hints, and a ready-to-use example_invocation\n you can pass directly to get_price.\n\n Use this before get_price when unsure of exact field names or values.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\". Empty = all providers.\n domain: Domain — \"compute\", \"storage\", \"database\", \"ai\", \"container\",\n \"serverless\", \"analytics\", \"network\", \"observability\". Empty = all.\n service: Service — e.g. \"bedrock\", \"rds\", \"gke\", \"bigquery\". Empty = all.\n " - descCompareBOMRegions = "\n Compare a Bill of Materials' total monthly cost across multiple AWS regions.\n\n v1 scope: AWS-only. Each item is an open PricingSpec dict, same shape as\n estimate_bom's items (provider, domain, resource_type/region/etc, plus\n quantity/hours_per_month/size_gb/description). The region field on each\n item is overridden per comparison — pass any region in the item dicts.\n Non-AWS items are reported once under \"not_supported\" rather than\n guessed or dropped silently; GCP/Azure support is tracked separately.\n\n Returns regions[] sorted cheapest-first, each with total_monthly, the\n resolved line_items, and any per-item errors. Optionally shows delta vs\n a baseline region.\n\n Args:\n items: List of PricingSpec dicts (same shape as estimate_bom).\n regions: List of AWS region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\"].\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n " + descCompareBOMRegions = "\n Compare a Bill of Materials' total monthly cost across multiple AWS regions.\n\n v1 scope: AWS-only. Each item is an open PricingSpec dict, same shape as\n estimate_bom's items (provider, domain, resource_type/region/etc, plus\n quantity/hours_per_month/size_gb/description) — or a raw-SKU dict\n (sku, region, plus optional service/operation/product_family) for a CUR\n usage-type/SKU string, AWS-only. The region field on each item is\n overridden per comparison — pass any region in the item dicts.\n Weighting and a providers filter are not supported yet. Non-AWS items\n are reported once under \"not_supported\" rather than guessed or dropped\n silently; GCP/Azure support is tracked separately.\n\n Returns regions[] sorted cheapest-first, each with total_monthly, the\n resolved line_items, and any per-item errors. Optionally shows delta vs\n a baseline region.\n\n Args:\n items: List of PricingSpec dicts (same shape as estimate_bom) — or\n raw-SKU dicts (sku, region, plus optional service/operation/\n product_family), AWS-only. See estimate_bom for full item format.\n regions: List of AWS region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\"].\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n " descGetCoverage = "\n Report which domains/services this server actually covers, per provider.\n\n v1 scope: structural coverage from the catalog only — each domain is\n reported as \"catalog\" (with its known services) unless the provider\n has no entry for it at all. This does NOT fan out a live get_price call\n per region — whether a specific region's live price is a real catalog\n rate or a degraded fallback constant is only observable by calling\n get_price for that spec and checking its \"fallback\" field, since that\n is a live fetch outcome rather than a fixed property of the catalog.\n\n Use this to answer \"what does this server know about\" before trial-\n and-error against describe_catalog and individual get_price calls.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\". Empty = all\n configured providers.\n " @@ -3019,7 +3027,7 @@ const ( descWarmCache = "\n Pre-populate the pricing cache for a provider before a large sweep (e.g. a\n multi-region compare_prices or get_prices_batch call), so that sweep hits a warm\n cache instead of paying fetch latency on every combination.\n\n Resolves each requested service to its catalog example_invocation (the same data\n describe_catalog returns) and fans the resulting spec x region combinations out\n concurrently, mirroring the compare_prices/get_prices_batch fan-out pattern.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\"\n regions: List of region codes to warm, e.g. [\"us-east-1\", \"eu-west-1\"]\n services: Optional list of service names or describe_catalog keys, e.g.\n [\"ec2\", \"rds\", \"compute/fargate\"]. Omit to warm every service the\n provider's catalog has an example invocation for.\n " - descEstimateBOM = "\n Use this tool for total infrastructure cost, TCO, monthly spend for a multi-resource\n stack, or cost comparison between architectures.\n\n Handles compute + storage + database + AI together in a single call — do NOT call\n get_price individually for multi-resource questions; use this tool instead.\n\n Returns per-item and total monthly/annual costs with real public pricing data,\n plus a not_included list of supplementary costs (egress, load balancers, monitoring).\n These are SUPPLEMENTARY — only price them if the user asked for TCO; for most\n questions just note 'additional costs may apply'.\n\n Each item should be a PricingSpec dict PLUS a quantity field:\n - provider: \"aws\" | \"gcp\" | \"azure\"\n - domain: \"compute\" | \"storage\" | \"database\" | \"ai\" | ...\n - region: region code\n - quantity: number of units (default 1)\n - hours_per_month: hours/month for compute (default 730 = always-on)\n - description: optional label for this line item\n Plus domain-specific fields (see get_price or describe_catalog for details).\n\n Examples:\n Compute + database + storage on AWS:\n [\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\", \"quantity\": 3},\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r6g.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"},\n {\"provider\": \"aws\", \"domain\": \"storage\", \"storage_type\": \"gp3\", \"size_gb\": 500, \"region\": \"us-east-1\"}\n ]\n\n Mixed cloud:\n [\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"quantity\": 2},\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\", \"quantity\": 1}\n ]\n " + descEstimateBOM = "\n Use this tool for total infrastructure cost, TCO, monthly spend for a multi-resource\n stack, or cost comparison between architectures.\n\n Handles compute + storage + database + AI together in a single call — do NOT call\n get_price individually for multi-resource questions; use this tool instead.\n\n Returns per-item and total monthly/annual costs with real public pricing data,\n plus a not_included list of supplementary costs (egress, load balancers, monitoring).\n These are SUPPLEMENTARY — only price them if the user asked for TCO; for most\n questions just note 'additional costs may apply'.\n\n Each item should be a PricingSpec dict PLUS a quantity field:\n - provider: \"aws\" | \"gcp\" | \"azure\"\n - domain: \"compute\" | \"storage\" | \"database\" | \"ai\" | ...\n - region: region code\n - quantity: number of units (default 1)\n - hours_per_month: hours/month for compute (default 730 = always-on)\n - description: optional label for this line item\n Plus domain-specific fields (see get_price or describe_catalog for details).\n\n An item may instead be a raw-SKU dict (AWS-only): {\"sku\": \"...\",\n \"region\": \"us-east-1\", \"quantity\": 3} — same CUR usage-type/SKU\n string get_price_by_sku resolves, optionally with service/operation/\n product_family hints to disambiguate.\n\n Examples:\n Compute + database + storage on AWS:\n [\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\", \"quantity\": 3},\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r6g.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"},\n {\"provider\": \"aws\", \"domain\": \"storage\", \"storage_type\": \"gp3\", \"size_gb\": 500, \"region\": \"us-east-1\"}\n ]\n\n Mixed cloud:\n [\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"quantity\": 2},\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\", \"quantity\": 1}\n ]\n " descEstimateUnitEconomics = "\n Estimate per-unit economics (cost per user, per request, per transaction) given\n a Bill of Materials and expected monthly usage volume.\n\n Args:\n items: Same format as estimate_bom — list of cloud resource PricingSpec dicts\n plus quantity field. See estimate_bom for full item format.\n units_per_month: Monthly volume being measured (e.g. 10000 users)\n unit_label: What the unit represents — \"user\", \"request\", \"transaction\", etc.\n " diff --git a/opencloudcosts-go/internal/tools/bom.go b/opencloudcosts-go/internal/tools/bom.go index 2723da5..5524583 100644 --- a/opencloudcosts-go/internal/tools/bom.go +++ b/opencloudcosts-go/internal/tools/bom.go @@ -6,16 +6,25 @@ // All monetary values use float64 arithmetic (not shopspring/decimal) per the // Phase 0 plan decision. The output shape mirrors the Python implementation // in src/opencloudcosts/tools/bom.py and src/opencloudcosts/tools/lookup.py. +// +// processBOMItems additionally resolves raw-SKU line items (issue #31, +// RC3-004) via resolveBOMSKUItem, which type-asserts a concrete +// *awsprovider.Provider to reuse LookupSKUAcrossRegions — the same AWS-only +// core get_price_by_sku uses (internal/tools/sku_lookup.go). That import is +// isolated to the resolveBOMSKUItem call site for the same reason +// sku_lookup.go isolates it: the rest of this file stays provider-agnostic. package tools import ( "context" + "errors" "fmt" "strings" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/models" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/providers" + awsprovider "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/providers/aws" ) // -------------------------------------------------------------------------- @@ -141,14 +150,17 @@ type bomLineItem struct { unitPrice models.NormalizedPrice monthlyCost float64 annualCost float64 + // sku is set only for line items resolved from a raw-SKU BoM entry + // (see resolveBOMSKUItem); empty for PricingSpec-dict items. + sku string } func (li bomLineItem) toMap() map[string]any { m := map[string]any{ - "description": li.description, - "provider": li.provider, - "service": li.service, - "region": li.region, + "description": li.description, + "provider": li.provider, + "service": li.service, + "region": li.region, "quantity": li.quantity, "price_per_unit": priceDict(li.unitPrice.PricePerUnit, string(li.unitPrice.Unit)), "monthly_cost": moneyDict(li.monthlyCost, "/mo"), @@ -167,9 +179,70 @@ func (li bomLineItem) toMap() map[string]any { } } + if li.sku != "" { + m["sku"] = li.sku + } + return m } +// -------------------------------------------------------------------------- +// Raw-SKU BoM item helpers — shared by processBOMItems (this file) and +// HandleCompareBOMRegions's partition loop (compare_bom_regions.go). +// -------------------------------------------------------------------------- + +// rawBOMSKU extracts and trims a raw-SKU BoM item's "sku" field, reporting +// whether one was present (a whitespace-only value does not count). Shared +// by processBOMItems and HandleCompareBOMRegions's partition loop +// (compare_bom_regions.go) so both treat "is this a raw-SKU item" — and the +// exact string handed to the AWS SKU resolver — identically. +func rawBOMSKU(item map[string]any) (string, bool) { + sku, _ := item["sku"].(string) + sku = strings.TrimSpace(sku) + return sku, sku != "" +} + +// stringItemField extracts item[key] as a string, distinguishing "absent" +// (returns "", "") from "present but not a string" (returns "", a non-empty +// error message) — a raw-SKU item with e.g. operation as a number/array +// should surface a clear type error rather than silently being treated as +// "field not supplied," which would otherwise produce a misleading +// disambiguation-hint error later. +func stringItemField(item map[string]any, key, label, sku string) (string, string) { + v, present := item[key] + if !present { + return "", "" + } + s, ok := v.(string) + if !ok { + return "", fmt.Sprintf("%s: %q must be a string (sku %q)", label, key, sku) + } + return s, "" +} + +// awsServiceCodeToAdvisoryToken maps a raw AWS Pricing API servicecode (as +// stored on raw-SKU line items' li.service — see resolveBOMSKUItem) to the +// short-form category token BOMAdvisories' svcSet already recognizes for +// PricingSpec-dict line items, so advisory rows (egress, LB, NAT, RDS +// backups, EBS snapshots) aren't silently skipped just because a BoM used +// raw-SKU items instead of PricingSpec dicts for the same AWS service. +var awsServiceCodeToAdvisoryToken = map[string]string{ + "amazonec2": "ec2", + "amazonrds": "rds", + "amazonelasticache": "elasticache", + "amazons3": "s3", + "amazonebs": "ebs", +} + +// bomAdvisoryServiceToken normalizes a bomLineItem.service value for the +// BOMAdvisories lookup — see awsServiceCodeToAdvisoryToken. +func bomAdvisoryServiceToken(service string) string { + if tok, ok := awsServiceCodeToAdvisoryToken[strings.ToLower(service)]; ok { + return tok + } + return service +} + // -------------------------------------------------------------------------- // processBOMItems is the shared item-processing loop for estimate_bom and // estimate_unit_economics. Returns (lineItems, errors). @@ -212,6 +285,20 @@ func processBOMItems( } description, _ := item["description"].(string) + // Raw-SKU items (issue #31, RC3-004) bypass the PricingSpec path + // entirely — they carry a CUR-style usage-type/SKU string instead of + // a domain/resource_type spec, so resolve them via the same AWS SKU + // lookup get_price_by_sku uses. + if sku, ok := rawBOMSKU(item); ok { + li, errMsg := resolveBOMSKUItem(ctx, provs, label, item, sku, quantity, hoursPerMonth, sizeGB, description) + if errMsg != "" { + errs = append(errs, errMsg) + continue + } + lineItems = append(lineItems, li) + continue + } + // Build clean spec dict (remove BoM-only fields). specDict := make(map[string]any, len(item)) for k, v := range item { @@ -333,6 +420,109 @@ func processBOMItems( return lineItems, errs } +// -------------------------------------------------------------------------- +// resolveBOMSKUItem resolves a raw-SKU BoM line item (issue #31, RC3-004) — +// mirrors resolveSKUPriceEntry's (sku_lookup.go) error-unwrapping and +// Prices/Ambiguous/NoMapping/Error discrimination exactly, but for a single +// region and shaped as a bomLineItem rather than get_price_by_sku's response +// map, since a BoM line item needs exactly one price to cost out. +// -------------------------------------------------------------------------- + +func resolveBOMSKUItem( + ctx context.Context, + provs map[string]Provider, + label string, + item map[string]any, + sku string, + quantity float64, + hoursPerMonth float64, + sizeGB float64, + description string, +) (bomLineItem, string) { + region, _ := item["region"].(string) + if region == "" { + return bomLineItem{}, fmt.Sprintf("%s: region is required for raw-SKU items (sku %q)", label, sku) + } + + providerName, errMsg := stringItemField(item, "provider", label, sku) + if errMsg != "" { + return bomLineItem{}, errMsg + } + if providerName == "" { + // Mirrors HandleGetPriceBySKU's default: raw usage-type/SKU strings + // are an AWS CUR concept, so an absent provider means "aws". + providerName = "aws" + } + + awsP, errOut := resolveAWSSKUProviderFromMap(provs, providerName, "raw-SKU BoM items") + if errOut != nil { + msg, _ := errOut["message"].(string) + return bomLineItem{}, fmt.Sprintf("%s: %s (sku %q)", label, msg, sku) + } + + serviceHint, errMsg := stringItemField(item, "service", label, sku) + if errMsg != "" { + return bomLineItem{}, errMsg + } + operation, errMsg := stringItemField(item, "operation", label, sku) + if errMsg != "" { + return bomLineItem{}, errMsg + } + productFamily, errMsg := stringItemField(item, "product_family", label, sku) + if errMsg != "" { + return bomLineItem{}, errMsg + } + + result, err := awsP.LookupSKUAcrossRegions(ctx, providerName, sku, serviceHint, []string{region}, operation, productFamily) + if err != nil { + var skuErr *awsprovider.SKULookupError + if errors.As(err, &skuErr) { + return bomLineItem{}, fmt.Sprintf("%s: [%s] %s (sku %q)", label, skuErr.Code, skuErr.Message, sku) + } + return bomLineItem{}, fmt.Sprintf("%s: SKU lookup failed (sku %q)", label, sku) + } + + rr := result.Regions[0] + switch classifySKURegionResult(rr) { + case skuResultAmbiguous: + return bomLineItem{}, fmt.Sprintf( + "%s: sku %q is ambiguous in region '%s' (%d matching rows) — supply operation/product_family to disambiguate", + label, sku, region, len(rr.Prices)) + case skuResultNoMapping: + return bomLineItem{}, fmt.Sprintf("%s: sku %q has no pricing mapping in region '%s' (tried service(s): %v)", + label, sku, region, rr.AttemptedServices) + case skuResultError: + return bomLineItem{}, fmt.Sprintf("%s: %s (sku %q, region '%s')", label, rr.Error, sku, region) + case skuResultUnresolved: + return bomLineItem{}, fmt.Sprintf("%s: sku %q could not be resolved in region '%s'", label, sku, region) + } + + price := rr.Prices[0] + monthly := bomMonthlyCost(price, quantity, hoursPerMonth, sizeGB) + annual := monthly * 12 + + lineDesc := description + if lineDesc == "" { + svc := rr.ServiceUsed + if svc == "" { + svc = price.Service + } + lineDesc = fmt.Sprintf("SKU %s (%s)", sku, svc) + } + + return bomLineItem{ + description: lineDesc, + provider: string(price.Provider), + service: price.Service, + region: price.Region, + quantity: quantity, + unitPrice: price, + monthlyCost: monthly, + annualCost: annual, + sku: sku, + }, "" +} + // -------------------------------------------------------------------------- // HandleEstimateBOM — estimate_bom tool handler // -------------------------------------------------------------------------- @@ -365,7 +555,7 @@ func (h *Handler) HandleEstimateBOM( providersInBoM := make(map[string]bool) providerFirstRegion := make(map[string]string) for _, li := range lineItems { - servicesSet[li.service] = struct{}{} + servicesSet[bomAdvisoryServiceToken(li.service)] = struct{}{} if !providersInBoM[li.provider] { providersInBoM[li.provider] = true providerFirstRegion[li.provider] = li.region diff --git a/opencloudcosts-go/internal/tools/bom_test.go b/opencloudcosts-go/internal/tools/bom_test.go index 08ec65a..e9107d4 100644 --- a/opencloudcosts-go/internal/tools/bom_test.go +++ b/opencloudcosts-go/internal/tools/bom_test.go @@ -5,11 +5,15 @@ package tools_test import ( "context" "errors" + "net/http" + "net/http/httptest" "strings" "testing" + "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/config" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/models" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/providers" + awsprovider "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/providers/aws" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/tools" ) @@ -1237,3 +1241,331 @@ func TestEstimateBOM_NoFallbackFlagWhenLive(t *testing.T) { t.Errorf("expected no \"fallback_note\" key on a live-priced line item, got %v", li["fallback_note"]) } } + +// -------------------------------------------------------------------------- +// Raw-SKU BoM line items (issue #31, RC3-004) +// -------------------------------------------------------------------------- + +// TestEstimateBOM_RawSKUItem verifies a raw-SKU item resolves against a real +// *awsprovider.Provider and contributes to the BoM total — same fixture/ +// mocking pattern as TestHandleGetPriceBySKU_HappyPath in sku_lookup_test.go, +// since resolveBOMSKUItem type-asserts the concrete AWS provider rather than +// going through the mockProvider interface. +func TestEstimateBOM_RawSKUItem(t *testing.T) { + awsprovider.ResetSKUCatalogCacheForTesting() + + mux := http.NewServeMux() + mux.HandleFunc("/AmazonEC2/current/us-east-1/index.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(skuFixtureJSON("SKU1", "BoxUsage:r6id.24xlarge", "US East (N. Virginia)", "0.5000000000"))) + }) + server := httptest.NewServer(mux) + defer server.Close() + restore := awsprovider.SetBulkPricingBaseURLForTesting(server.URL) + defer restore() + + realAWS, err := awsprovider.NewProvider(&config.Config{}, nil) + if err != nil { + t.Fatalf("awsprovider.NewProvider: %v", err) + } + h := tools.New(map[string]tools.Provider{"aws": realAWS}) + + items := []map[string]any{ + { + "sku": "BoxUsage:r6id.24xlarge", + "service": "AmazonEC2", + "region": "us-east-1", + "quantity": float64(1), + }, + } + resp := callEstimateBOM(t, h, items) + + if _, ok := resp["error"]; ok { + t.Fatalf("expected success, got error: %v", resp["error"]) + } + + lineItems, ok := resp["line_items"].([]any) + if !ok || len(lineItems) != 1 { + t.Fatalf("expected 1 line item, got %v", resp["line_items"]) + } + li := lineItems[0].(map[string]any) + if li["sku"] != "BoxUsage:r6id.24xlarge" { + t.Errorf("expected sku field populated, got %v", li["sku"]) + } + + totals, ok := resp["totals"].(map[string]any) + if !ok { + t.Fatalf("expected totals in response, got %v", resp["totals"]) + } + monthly := totals["monthly"].(map[string]any) + // 0.50/hr * 730 hrs/mo (default) * quantity 1 = $365.00/mo. + if monthly["display"] != "$365.00/mo" { + t.Errorf("expected total monthly $365.00/mo, got %v", monthly["display"]) + } +} + +// TestEstimateBOM_RawSKUItem_PartialFailureNoMapping verifies that when a +// two-item BoM mixes a resolvable raw-SKU item with one whose usage-type +// suffix has no matching row in its region's catalog (the requested-region +// fetch succeeds, but no product row matches — see aws_sku_lookup.go's +// NoMapping branch), the good item still resolves and contributes to +// total_monthly while the bad item surfaces only as a per-item error +// entry — mirroring TestEstimateBOM_PartialFailure's "errs, not a top-level +// error" contract for the raw-SKU path. +func TestEstimateBOM_RawSKUItem_PartialFailureNoMapping(t *testing.T) { + awsprovider.ResetSKUCatalogCacheForTesting() + + mux := http.NewServeMux() + mux.HandleFunc("/AmazonEC2/current/us-east-1/index.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + // Catalog only contains "BoxUsage:r6id.24xlarge" — a lookup for any + // other suffix (e.g. "BoxUsage:doesnotexist" below) hits NoMapping. + _, _ = w.Write([]byte(skuFixtureJSON("SKU1", "BoxUsage:r6id.24xlarge", "US East (N. Virginia)", "0.5000000000"))) + }) + server := httptest.NewServer(mux) + defer server.Close() + restore := awsprovider.SetBulkPricingBaseURLForTesting(server.URL) + defer restore() + + realAWS, err := awsprovider.NewProvider(&config.Config{}, nil) + if err != nil { + t.Fatalf("awsprovider.NewProvider: %v", err) + } + h := tools.New(map[string]tools.Provider{"aws": realAWS}) + + items := []map[string]any{ + { + "sku": "BoxUsage:r6id.24xlarge", + "service": "AmazonEC2", + "region": "us-east-1", + "quantity": float64(1), + }, + { + "sku": "BoxUsage:doesnotexist", + "service": "AmazonEC2", + "region": "us-east-1", + "quantity": float64(1), + }, + } + resp := callEstimateBOM(t, h, items) + + // Must NOT have a top-level "error" key — one item succeeded. + if topErr, ok := resp["error"]; ok { + t.Fatalf("expected no top-level error for partial failure, got: %v", topErr) + } + + lineItems, ok := resp["line_items"].([]any) + if !ok || len(lineItems) != 1 { + t.Fatalf("expected 1 successful line item, got: %v", resp["line_items"]) + } + li := lineItems[0].(map[string]any) + if li["sku"] != "BoxUsage:r6id.24xlarge" { + t.Errorf("expected the resolvable sku on the surviving line item, got %v", li["sku"]) + } + + errsVal := resp["errors"] + if errsVal == nil { + t.Fatal("expected errors field to be set for the unmapped item, got nil") + } + errs, ok := errsVal.([]any) + if !ok || len(errs) != 1 { + t.Fatalf("expected exactly 1 error entry, got: %v", errsVal) + } + errStr, _ := errs[0].(string) + if !strings.Contains(errStr, "no pricing mapping") { + t.Errorf("expected error mentioning 'no pricing mapping', got %q", errStr) + } + + totals, ok := resp["totals"].(map[string]any) + if !ok { + t.Fatalf("expected totals in response, got %v", resp["totals"]) + } + monthly := totals["monthly"].(map[string]any) + // Only the resolvable item contributes: 0.50/hr * 730 hrs/mo * qty 1 = $365.00/mo. + if monthly["display"] != "$365.00/mo" { + t.Errorf("expected total monthly $365.00/mo (unmapped item excluded), got %v", monthly["display"]) + } +} + +// TestEstimateBOM_RawSKUItemTrimsWhitespace verifies a raw-SKU item whose +// "sku" carries leading/trailing whitespace (e.g. a copy-pasted CUR export +// column) still resolves — the whitespace must be trimmed before being +// handed to the AWS SKU resolver (Finding 3 fix), not just before the +// raw-SKU-detection check. +func TestEstimateBOM_RawSKUItemTrimsWhitespace(t *testing.T) { + awsprovider.ResetSKUCatalogCacheForTesting() + + mux := http.NewServeMux() + mux.HandleFunc("/AmazonEC2/current/us-east-1/index.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(skuFixtureJSON("SKU1", "BoxUsage:m5.xlarge", "US East (N. Virginia)", "0.1920000000"))) + }) + server := httptest.NewServer(mux) + defer server.Close() + restore := awsprovider.SetBulkPricingBaseURLForTesting(server.URL) + defer restore() + + realAWS, err := awsprovider.NewProvider(&config.Config{}, nil) + if err != nil { + t.Fatalf("awsprovider.NewProvider: %v", err) + } + h := tools.New(map[string]tools.Provider{"aws": realAWS}) + + items := []map[string]any{ + { + "sku": " BoxUsage:m5.xlarge ", + "service": "AmazonEC2", + "region": "us-east-1", + "quantity": float64(1), + }, + } + resp := callEstimateBOM(t, h, items) + + if _, ok := resp["error"]; ok { + t.Fatalf("expected success, got error: %v", resp["error"]) + } + if errsVal := resp["errors"]; errsVal != nil { + t.Fatalf("expected no per-item errors, got: %v", errsVal) + } + + lineItems, ok := resp["line_items"].([]any) + if !ok || len(lineItems) != 1 { + t.Fatalf("expected 1 line item (whitespace-padded sku trimmed and resolved), got %v", resp["line_items"]) + } +} + +// TestEstimateBOM_RawSKUItemNonStringProviderRejected verifies a raw-SKU +// item whose "provider" field is present but not a string (e.g. a number +// from a caller bug) produces a clear type error rather than silently +// defaulting to "aws" (Finding 4 fix). The provider-type check runs before +// any provider resolution or network call, so no AWS provider fixture is +// needed here. +func TestEstimateBOM_RawSKUItemNonStringProviderRejected(t *testing.T) { + h := tools.New(nil) + + items := []map[string]any{ + { + "sku": "BoxUsage:m5.xlarge", + "service": "AmazonEC2", + "region": "us-east-1", + "provider": float64(123), + }, + } + resp := callEstimateBOM(t, h, items) + + errVal, ok := resp["error"] + if !ok { + t.Fatalf("expected an error for a non-string provider, got: %v", resp) + } + errStr, _ := errVal.(string) + if !strings.Contains(errStr, "provider") || !strings.Contains(errStr, "string") { + t.Errorf("expected error to mention 'provider' and 'string', got %q", errStr) + } +} + +// TestEstimateBOM_RawSKUItemNonStringOperationRejected verifies a raw-SKU +// item whose "operation" field is present but not a string (e.g. an array) +// produces a clear type error, rather than being silently treated as "no +// hint supplied" and surfacing the misleading ambiguous/disambiguate message +// (Finding 5 fix). Unlike the provider check, this one runs after provider +// resolution succeeds, so a real *awsprovider.Provider is required. +func TestEstimateBOM_RawSKUItemNonStringOperationRejected(t *testing.T) { + awsprovider.ResetSKUCatalogCacheForTesting() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + defer server.Close() + restore := awsprovider.SetBulkPricingBaseURLForTesting(server.URL) + defer restore() + + realAWS, err := awsprovider.NewProvider(&config.Config{}, nil) + if err != nil { + t.Fatalf("awsprovider.NewProvider: %v", err) + } + h := tools.New(map[string]tools.Provider{"aws": realAWS}) + + items := []map[string]any{ + { + "sku": "BoxUsage:m5.xlarge", + "service": "AmazonEC2", + "region": "us-east-1", + "operation": []any{"CreateDBInstance"}, + }, + } + resp := callEstimateBOM(t, h, items) + + errVal, ok := resp["error"] + if !ok { + t.Fatalf("expected an error for a non-string operation, got: %v", resp) + } + errStr, _ := errVal.(string) + if !strings.Contains(errStr, "operation") || !strings.Contains(errStr, "string") { + t.Errorf("expected error to mention 'operation' and 'string', got %q", errStr) + } + if strings.Contains(errStr, "ambiguous") || strings.Contains(errStr, "disambiguate") { + t.Errorf("expected a type error, not the ambiguous/disambiguate message, got %q", errStr) + } +} + +// TestEstimateBOM_RawSKUItemAdvisoriesIncluded verifies Finding 2's fix: a +// raw-SKU EC2 item's li.service (the raw AWS Pricing API servicecode +// "AmazonEC2") is normalized via bomAdvisoryServiceToken before being fed to +// BOMAdvisories, so egress/LB/NAT advisory rows are still produced for a BoM +// built entirely from raw-SKU items. +func TestEstimateBOM_RawSKUItemAdvisoriesIncluded(t *testing.T) { + awsprovider.ResetSKUCatalogCacheForTesting() + + mux := http.NewServeMux() + mux.HandleFunc("/AmazonEC2/current/us-east-1/index.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(skuFixtureJSON("SKU1", "BoxUsage:m5.xlarge", "US East (N. Virginia)", "0.1920000000"))) + }) + server := httptest.NewServer(mux) + defer server.Close() + restore := awsprovider.SetBulkPricingBaseURLForTesting(server.URL) + defer restore() + + realAWS, err := awsprovider.NewProvider(&config.Config{}, nil) + if err != nil { + t.Fatalf("awsprovider.NewProvider: %v", err) + } + h := tools.New(map[string]tools.Provider{"aws": realAWS}) + + items := []map[string]any{ + { + "sku": "BoxUsage:m5.xlarge", + "service": "AmazonEC2", + "region": "us-east-1", + "quantity": float64(1), + }, + } + resp := callEstimateBOM(t, h, items) + + if _, ok := resp["error"]; ok { + t.Fatalf("expected success, got error: %v", resp["error"]) + } + + notIncluded, ok := resp["not_included"].([]any) + if !ok || len(notIncluded) == 0 { + t.Fatalf("expected non-empty not_included advisories for a raw-SKU EC2 item, got: %v", resp["not_included"]) + } + + found := false + for _, row := range notIncluded { + m, ok := row.(map[string]any) + if !ok { + continue + } + if item, _ := m["item"].(string); strings.Contains(item, "Data transfer") { + found = true + break + } + } + if !found { + t.Errorf("expected a 'Data transfer (egress)' advisory row, got: %v", notIncluded) + } +} diff --git a/opencloudcosts-go/internal/tools/compare_bom_regions.go b/opencloudcosts-go/internal/tools/compare_bom_regions.go index 11e2b44..b623d71 100644 --- a/opencloudcosts-go/internal/tools/compare_bom_regions.go +++ b/opencloudcosts-go/internal/tools/compare_bom_regions.go @@ -1,8 +1,9 @@ // compare_bom_regions.go implements the compare_bom_regions MCP tool. // // v1 scope (issue #31, RC3-004): AWS-only, synchronous per-line region -// fan-out over PricingSpec-dict items — no raw-SKU line items or weighting -// yet. It is composed entirely from existing cross-provider machinery — +// fan-out over PricingSpec-dict and raw-SKU items — no weighting or a +// providers filter yet. It is composed entirely from existing cross-provider +// machinery — // estimate_bom's processBOMItems (bom.go) for per-item price resolution, and // compare_prices' region-fan-out + baseline-delta pattern (this file) for the // region loop — rather than new AWS-specific plumbing, so the input/output @@ -58,6 +59,27 @@ func (h *Handler) HandleCompareBOMRegions( var notSupported []map[string]any for idx, item := range in.Items { label := fmt.Sprintf("Item %d", idx+1) + + // Raw-SKU items are implicitly AWS (same default get_price_by_sku + // applies to a missing provider) — but an item that explicitly names + // a non-AWS provider is routed to notSupported here, exactly like any + // other non-AWS item, rather than being rejected once per region + // inside processBOMItems below. + if _, ok := rawBOMSKU(item); ok { + pvdrName, hasPvdr := item["provider"].(string) + if !hasPvdr || pvdrName == "" || strings.EqualFold(pvdrName, compareBOMRegionsV1Provider) { + resolvable = append(resolvable, item) + continue + } + notSupported = append(notSupported, map[string]any{ + "item": label, + "provider": pvdrName, + "source": "not_supported", + "reason": "compare_bom_regions v1 is AWS-only (RC3-004) — this provider is not yet supported.", + }) + continue + } + pvdrName, _ := item["provider"].(string) if strings.ToLower(pvdrName) != compareBOMRegionsV1Provider { notSupported = append(notSupported, map[string]any{ diff --git a/opencloudcosts-go/internal/tools/compare_bom_regions_test.go b/opencloudcosts-go/internal/tools/compare_bom_regions_test.go index f59db0a..014f6cb 100644 --- a/opencloudcosts-go/internal/tools/compare_bom_regions_test.go +++ b/opencloudcosts-go/internal/tools/compare_bom_regions_test.go @@ -2,10 +2,14 @@ package tools_test import ( "context" + "net/http" + "net/http/httptest" "testing" + "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/config" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/models" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/providers" + awsprovider "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/providers/aws" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/tools" ) @@ -153,3 +157,101 @@ func TestCompareBOMRegions_BaselineRegionNotFound(t *testing.T) { t.Errorf("expected nulled delta_monthly when baseline not found, got %v", region["delta_monthly"]) } } + +// TestCompareBOMRegions_RawSKUItem verifies a raw-SKU BoM item (issue #31, +// RC3-004) resolves per region against a real *awsprovider.Provider — same +// fixture/mocking pattern as TestHandleGetPriceBySKU_HappyPath in +// sku_lookup_test.go, since resolveBOMSKUItem type-asserts the concrete AWS +// provider rather than going through the mockProvider interface. +func TestCompareBOMRegions_RawSKUItem(t *testing.T) { + awsprovider.ResetSKUCatalogCacheForTesting() + + mux := http.NewServeMux() + mux.HandleFunc("/AmazonEC2/current/us-east-1/index.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(skuFixtureJSON("SKU1", "BoxUsage:r6id.24xlarge", "US East (N. Virginia)", "0.5000000000"))) + }) + mux.HandleFunc("/AmazonEC2/current/us-west-2/index.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(skuFixtureJSON("SKU2", "USW2-BoxUsage:r6id.24xlarge", "US West (Oregon)", "0.6000000000"))) + }) + server := httptest.NewServer(mux) + defer server.Close() + restore := awsprovider.SetBulkPricingBaseURLForTesting(server.URL) + defer restore() + + realAWS, err := awsprovider.NewProvider(&config.Config{}, nil) + if err != nil { + t.Fatalf("awsprovider.NewProvider: %v", err) + } + h := tools.New(map[string]tools.Provider{"aws": realAWS}) + + resp := callCompareBOMRegions(t, h, tools.CompareBOMRegionsInput{ + Items: []map[string]any{ + {"sku": "BoxUsage:r6id.24xlarge", "service": "AmazonEC2", "quantity": float64(2)}, + }, + Regions: []string{"us-east-1", "us-west-2"}, + }) + + regions, ok := resp["regions"].([]any) + if !ok || len(regions) != 2 { + t.Fatalf("expected 2 region entries, got: %v", resp["regions"]) + } + + first := regions[0].(map[string]any) + if first["region"] != "us-east-1" { + t.Errorf("expected cheapest region us-east-1 first, got %v", first["region"]) + } + lineItems, ok := first["line_items"].([]any) + if !ok || len(lineItems) != 1 { + t.Fatalf("expected 1 line item for us-east-1, got: %v", first["line_items"]) + } + li := lineItems[0].(map[string]any) + if li["sku"] != "BoxUsage:r6id.24xlarge" { + t.Errorf("expected sku field populated, got %v", li["sku"]) + } + monthly := li["monthly_cost"].(map[string]any) + // 0.50/hr * 730 hrs/mo (default) * quantity 2 = $730.00/mo. + if monthly["display"] != "$730.00/mo" { + t.Errorf("expected monthly_cost $730.00/mo, got %v", monthly["display"]) + } + + last := regions[1].(map[string]any) + if last["region"] != "us-west-2" { + t.Errorf("expected us-west-2 second (more expensive), got %v", last["region"]) + } +} + +// TestCompareBOMRegions_RawSKUNonAWSProviderReportedOnce verifies a raw-SKU +// item with an explicit non-AWS provider is reported once in not_supported +// (Finding 1 fix), not duplicated once per compared region. +func TestCompareBOMRegions_RawSKUNonAWSProviderReportedOnce(t *testing.T) { + pvdr := newRegionPricedProvider(map[string]float64{"us-east-1": 0.192, "us-west-2": 0.150}) + h := tools.New(map[string]tools.Provider{"aws": pvdr}) + + resp := callCompareBOMRegions(t, h, tools.CompareBOMRegionsInput{ + Items: []map[string]any{ + {"sku": "BoxUsage:m5.xlarge", "provider": "gcp", "service": "AmazonEC2"}, + }, + Regions: []string{"us-east-1", "us-west-2"}, + }) + + notSupported, ok := resp["not_supported"].([]any) + if !ok || len(notSupported) != 1 { + t.Fatalf("expected exactly 1 not_supported entry, got: %v", resp["not_supported"]) + } + entry := notSupported[0].(map[string]any) + if entry["provider"] != "gcp" { + t.Errorf("expected gcp in not_supported entry, got %v", entry) + } + + regions := resp["regions"].([]any) + for _, r := range regions { + region := r.(map[string]any) + if errs, ok := region["errors"].([]any); ok && len(errs) > 0 { + t.Errorf("expected no per-region errors for the gcp raw-SKU item (should be reported once at top level), got: %v in region %v", errs, region["region"]) + } + } +} diff --git a/opencloudcosts-go/internal/tools/sku_lookup.go b/opencloudcosts-go/internal/tools/sku_lookup.go index d3a7554..f54c31f 100644 --- a/opencloudcosts-go/internal/tools/sku_lookup.go +++ b/opencloudcosts-go/internal/tools/sku_lookup.go @@ -119,22 +119,19 @@ func (h *Handler) HandleGetPriceBySKU( return jsonText(h.resolveSKUPriceEntry(ctx, awsP, providerName, in)), nil, nil } -// resolveAWSSKUProvider resolves and type-asserts the AWS provider for -// providerName, shared by get_price_by_sku and get_prices_by_sku (both -// AWS-only — raw usage-type/SKU strings are an AWS CUR concept with no GCP/ -// Azure equivalent). toolName is interpolated into the error message so each -// caller's error reads as coming from itself. Returns a non-nil errOut (and -// a nil *awsprovider.Provider) when resolution fails; callers must check -// errOut before using the returned provider. -func (h *Handler) resolveAWSSKUProvider(providerName, toolName string) (awsP *awsprovider.Provider, errOut map[string]any) { - pvdr := h.provider(strings.ToLower(providerName)) +// resolveAWSSKUProviderFromMap is the provider-agnostic core of +// resolveAWSSKUProvider, extracted so raw-SKU BoM item resolution +// (resolveBOMSKUItem in bom.go) can share the identical provider-resolution +// and type-assertion logic without needing a *Handler receiver — +// processBOMItems already threads a plain provs map, not a Handler. +func resolveAWSSKUProviderFromMap(provs map[string]Provider, providerName, toolName string) (*awsprovider.Provider, map[string]any) { + pvdr := provs[strings.ToLower(providerName)] if pvdr == nil { return nil, map[string]any{ "error": "unsupported_provider", "message": fmt.Sprintf("%s only supports provider=\"aws\" (got %q).", toolName, providerName), } } - awsP, ok := pvdr.(*awsprovider.Provider) if !ok { // Should not be reachable in practice (only "aws" resolves to an AWS @@ -148,6 +145,47 @@ func (h *Handler) resolveAWSSKUProvider(providerName, toolName string) (awsP *aw return awsP, nil } +// resolveAWSSKUProvider resolves and type-asserts the AWS provider for +// providerName, shared by get_price_by_sku and get_prices_by_sku (both +// AWS-only — raw usage-type/SKU strings are an AWS CUR concept with no GCP/ +// Azure equivalent). toolName is interpolated into the error message so each +// caller's error reads as coming from itself. Returns a non-nil errOut (and +// a nil *awsprovider.Provider) when resolution fails; callers must check +// errOut before using the returned provider. +func (h *Handler) resolveAWSSKUProvider(providerName, toolName string) (awsP *awsprovider.Provider, errOut map[string]any) { + return resolveAWSSKUProviderFromMap(h.providers, providerName, toolName) +} + +// skuRegionResultKind classifies a single awsprovider.SKULookupRegionResult +// into exactly one of five buckets. resolveSKUPriceEntry (looping over every +// region) and resolveBOMSKUItem (bom.go, a single region) both need this +// same four-way discrimination over Prices/Ambiguous/NoMapping/Error — kept +// in one place here rather than reimplemented at each call site. +type skuRegionResultKind int + +const ( + skuResultMatched skuRegionResultKind = iota + skuResultAmbiguous + skuResultNoMapping + skuResultError + skuResultUnresolved // none of Prices/NoMapping/Error set — should not occur in practice +) + +func classifySKURegionResult(rr awsprovider.SKULookupRegionResult) skuRegionResultKind { + switch { + case len(rr.Prices) > 0 && !rr.Ambiguous: + return skuResultMatched + case len(rr.Prices) > 0 && rr.Ambiguous: + return skuResultAmbiguous + case rr.NoMapping: + return skuResultNoMapping + case rr.Error != "": + return skuResultError + default: + return skuResultUnresolved + } +} + // resolveSKUPriceEntry resolves a single SKU against awsP/regions and shapes // the response. This is the shared core of get_price_by_sku (a single SKU) // and get_prices_by_sku (a batch of SKUs) — the disambiguation/sorting/ @@ -210,8 +248,8 @@ func (h *Handler) resolveSKUPriceEntry( anyAmbiguous := false for _, rr := range result.Regions { - switch { - case len(rr.Prices) > 0 && !rr.Ambiguous: + switch classifySKURegionResult(rr) { + case skuResultMatched: // resolveSKUCandidates guarantees exactly one row whenever it // reports ambiguous=false. matched = append(matched, matchedRegion{ @@ -221,7 +259,7 @@ func (h *Handler) resolveSKUPriceEntry( mismatch: rr.ServiceMismatch, hintStatus: rr.HintStatus, }) - case len(rr.Prices) > 0 && rr.Ambiguous: + case skuResultAmbiguous: // Still ambiguous even after hint-based and canonical-default // narrowing: this region is deliberately excluded from matched // (and therefore from sorting, cheapest/most_expensive, and @@ -241,12 +279,12 @@ func (h *Handler) resolveSKUPriceEntry( ar["service_mismatch"] = true } ambiguousRegions = append(ambiguousRegions, ar) - case rr.NoMapping: + case skuResultNoMapping: noMapping = append(noMapping, map[string]any{ "region": rr.Region, "attempted_services": rr.AttemptedServices, }) - case rr.Error != "": + case skuResultError: erroredRegions = append(erroredRegions, map[string]any{ "region": rr.Region, "error": rr.Error, diff --git a/opencloudcosts-go/schemas/tools-output-snapshot.json b/opencloudcosts-go/schemas/tools-output-snapshot.json index 959fb8d..36f6877 100644 --- a/opencloudcosts-go/schemas/tools-output-snapshot.json +++ b/opencloudcosts-go/schemas/tools-output-snapshot.json @@ -1542,6 +1542,9 @@ }, "fallback_note": { "type": "string" + }, + "sku": { + "type": "string" } } } @@ -1803,6 +1806,9 @@ }, "fallback_note": { "type": "string" + }, + "sku": { + "type": "string" } } } diff --git a/opencloudcosts-go/schemas/tools-snapshot.json b/opencloudcosts-go/schemas/tools-snapshot.json index 2cbabdc..5d1bc6c 100644 --- a/opencloudcosts-go/schemas/tools-snapshot.json +++ b/opencloudcosts-go/schemas/tools-snapshot.json @@ -2,7 +2,7 @@ "tools": [ { "name": "get_price", - "description": "\n Unified pricing tool — returns public catalog rates plus contracted/effective prices\n where credentials are available.\n\n Pass a spec dict with at minimum: provider, domain, region.\n Domain-specific required fields (call describe_catalog for the complete list):\n\n COMPUTE : resource_type (\"m5.xlarge\" / \"n1-standard-4\" / \"Standard_D4s_v3\")\n os (\"Linux\" or \"Windows\"), term (\"on_demand\"/\"spot\"/\"cud_1yr\")\n Fargate: vcpu (e.g. 2.0), memory_gb (e.g. 4.0), service=\"fargate\"\n STORAGE : storage_type (\"gp3\"/\"io2\"/\"sc1\"/\"standard\"/\"nearline\"/\"pd-extreme\"/\"hyperdisk-extreme\"/\"premium-ssd\")\n size_gb — disk size for monthly estimate\n iops — provisioned IOPS for io1/io2 (AWS) or pd-extreme/hyperdisk-extreme (GCP)\n throughput_mbps — provisioned throughput MB/s for gp3 (AWS); charge above 125 MB/s baseline\n DATABASE : resource_type (\"db.r5.large\"/\"db-n1-standard-4\"), engine (\"MySQL\"),\n deployment (\"single-az\"/\"ha\"/\"multi-az\"), service (\"rds\"/\"cloud_sql\"/\"memorystore\")\n AI : model (\"claude-3-5-sonnet\"/\"gemini-1.5-flash\"), service (\"bedrock\"/\"gemini\"/\"vertex\"),\n input_tokens, output_tokens | machine_type + task for Vertex\n CONTAINER: service (\"gke\"/\"eks\"), mode (\"standard\"/\"autopilot\"), node_count, vcpu, memory_gb\n ANALYTICS: service (\"bigquery\"), query_tb, active_storage_gb, longterm_storage_gb, streaming_gb\n NETWORK : service (\"cloud_lb\"/\"cloud_cdn\"/\"cloud_nat\"/\"cloud_armor\"),\n lb_type, rule_count, data_gb, gateway_count, egress_gb, policy_count\n OBSERVABILITY: service (\"cloudwatch\"/\"cloud_monitoring\"), ingestion_mib, log_gb\n INTER_REGION_EGRESS: source_region, dest_region (empty = internet), data_gb\n Example: {\"provider\": \"aws\", \"domain\": \"inter_region_egress\",\n \"source_region\": \"us-east-1\", \"dest_region\": \"eu-west-1\"}\n\n Returns public_prices[] always. When auth exists: contracted_prices[], effective_price,\n auth_available=true.\n\n Call describe_catalog(provider, domain, service) for an example_invocation you can\n copy directly into this tool.\n\n Args:\n spec: PricingSpec dict — see field descriptions above.\n\n Examples:\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\"}\n {\"provider\": \"aws\", \"domain\": \"ai\", \"service\": \"bedrock\", \"model\": \"claude-3-5-sonnet\", \"region\": \"us-east-1\", \"input_tokens\": 1000000, \"output_tokens\": 1000000}\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"term\": \"cud_1yr\"}\n {\"provider\": \"gcp\", \"domain\": \"analytics\", \"service\": \"bigquery\", \"query_tb\": 10.0, \"active_storage_gb\": 500.0, \"region\": \"us\"}\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\"}\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r5.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"}\n ", + "description": "\n Unified pricing tool \u2014 returns public catalog rates plus contracted/effective prices\n where credentials are available.\n\n Pass a spec dict with at minimum: provider, domain, region.\n Domain-specific required fields (call describe_catalog for the complete list):\n\n COMPUTE : resource_type (\"m5.xlarge\" / \"n1-standard-4\" / \"Standard_D4s_v3\")\n os (\"Linux\" or \"Windows\"), term (\"on_demand\"/\"spot\"/\"cud_1yr\")\n Fargate: vcpu (e.g. 2.0), memory_gb (e.g. 4.0), service=\"fargate\"\n STORAGE : storage_type (\"gp3\"/\"io2\"/\"sc1\"/\"standard\"/\"nearline\"/\"pd-extreme\"/\"hyperdisk-extreme\"/\"premium-ssd\")\n size_gb \u2014 disk size for monthly estimate\n iops \u2014 provisioned IOPS for io1/io2 (AWS) or pd-extreme/hyperdisk-extreme (GCP)\n throughput_mbps \u2014 provisioned throughput MB/s for gp3 (AWS); charge above 125 MB/s baseline\n DATABASE : resource_type (\"db.r5.large\"/\"db-n1-standard-4\"), engine (\"MySQL\"),\n deployment (\"single-az\"/\"ha\"/\"multi-az\"), service (\"rds\"/\"cloud_sql\"/\"memorystore\")\n AI : model (\"claude-3-5-sonnet\"/\"gemini-1.5-flash\"), service (\"bedrock\"/\"gemini\"/\"vertex\"),\n input_tokens, output_tokens | machine_type + task for Vertex\n CONTAINER: service (\"gke\"/\"eks\"), mode (\"standard\"/\"autopilot\"), node_count, vcpu, memory_gb\n ANALYTICS: service (\"bigquery\"), query_tb, active_storage_gb, longterm_storage_gb, streaming_gb\n NETWORK : service (\"cloud_lb\"/\"cloud_cdn\"/\"cloud_nat\"/\"cloud_armor\"),\n lb_type, rule_count, data_gb, gateway_count, egress_gb, policy_count\n OBSERVABILITY: service (\"cloudwatch\"/\"cloud_monitoring\"), ingestion_mib, log_gb\n INTER_REGION_EGRESS: source_region, dest_region (empty = internet), data_gb\n Example: {\"provider\": \"aws\", \"domain\": \"inter_region_egress\",\n \"source_region\": \"us-east-1\", \"dest_region\": \"eu-west-1\"}\n\n Returns public_prices[] always. When auth exists: contracted_prices[], effective_price,\n auth_available=true.\n\n Call describe_catalog(provider, domain, service) for an example_invocation you can\n copy directly into this tool.\n\n Args:\n spec: PricingSpec dict \u2014 see field descriptions above.\n\n Examples:\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\"}\n {\"provider\": \"aws\", \"domain\": \"ai\", \"service\": \"bedrock\", \"model\": \"claude-3-5-sonnet\", \"region\": \"us-east-1\", \"input_tokens\": 1000000, \"output_tokens\": 1000000}\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"term\": \"cud_1yr\"}\n {\"provider\": \"gcp\", \"domain\": \"analytics\", \"service\": \"bigquery\", \"query_tb\": 10.0, \"active_storage_gb\": 500.0, \"region\": \"us\"}\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\"}\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r5.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"}\n ", "inputSchema": { "properties": { "spec": { @@ -25,7 +25,7 @@ }, { "name": "get_prices_batch", - "description": "\n Get prices for multiple compute instance types in a single region in one call.\n\n Fetches all prices concurrently. Useful for comparing a shortlist of candidate\n instance types (e.g. m5.xlarge vs c5.xlarge vs r5.xlarge) without separate calls.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\"\n instance_types: List of instance types, e.g. [\"m5.xlarge\", \"c5.xlarge\", \"r5.large\"]\n region: Region code, e.g. \"us-east-1\" or \"us-central1\"\n os: Operating system — \"Linux\" (default) or \"Windows\"\n term: Pricing term — \"on_demand\" (default), \"spot\", \"reserved_1yr\", \"cud_1yr\"\n ", + "description": "\n Get prices for multiple compute instance types in a single region in one call.\n\n Fetches all prices concurrently. Useful for comparing a shortlist of candidate\n instance types (e.g. m5.xlarge vs c5.xlarge vs r5.xlarge) without separate calls.\n\n Args:\n provider: Cloud provider \u2014 \"aws\", \"gcp\", or \"azure\"\n instance_types: List of instance types, e.g. [\"m5.xlarge\", \"c5.xlarge\", \"r5.large\"]\n region: Region code, e.g. \"us-east-1\" or \"us-central1\"\n os: Operating system \u2014 \"Linux\" (default) or \"Windows\"\n term: Pricing term \u2014 \"on_demand\" (default), \"spot\", \"reserved_1yr\", \"cud_1yr\"\n ", "inputSchema": { "properties": { "provider": { @@ -70,7 +70,7 @@ }, { "name": "compare_prices", - "description": "\n Compare pricing for any service across multiple regions.\n\n Fetches concurrently. Returns results sorted cheapest first, with % delta between\n cheapest and most expensive. Optionally shows delta vs a baseline region.\n\n Args:\n spec: PricingSpec dict (same as get_price). The region field is overridden\n per comparison — you can pass any region in the spec.\n regions: List of region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\", \"ap-northeast-1\"]\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n ", + "description": "\n Compare pricing for any service across multiple regions.\n\n Fetches concurrently. Returns results sorted cheapest first, with % delta between\n cheapest and most expensive. Optionally shows delta vs a baseline region.\n\n Args:\n spec: PricingSpec dict (same as get_price). The region field is overridden\n per comparison \u2014 you can pass any region in the spec.\n regions: List of region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\", \"ap-northeast-1\"]\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n ", "inputSchema": { "properties": { "spec": { @@ -106,7 +106,7 @@ }, { "name": "get_price_by_sku", - "description": "\n Resolve a raw AWS usage-type/SKU string — exactly as it appears in a Cost & Usage Report\n (CUR) export, e.g. \"CAN1-BoxUsage:r5a.8xlarge\" — to a price, across one or more regions.\n\n Use this instead of get_price/compare_prices when you have a raw billing export line item\n (a \"UsageType\" or \"SKU\" column value) and need to reconcile it against current public\n pricing, rather than starting from a known resource_type/domain spec. This tool strips the\n region-prefix token from the usage-type string (e.g. \"CAN1-\", \"EU-\", or no prefix at all\n for us-east-1) to get a region-independent suffix, then matches that suffix against each\n target region's pricing catalog.\n\n If service is omitted, the AWS servicecode is inferred from the usage-type pattern (e.g.\n \"BoxUsage:\" implies AmazonEC2, \"LCUUsage\" implies AWSELB) — service_source in the response\n indicates \"explicit\" or \"inferred\". If a supplied service hint finds no match but the\n inferred servicecode does (real CUR data isn't always internally consistent — e.g. data-\n transfer usage types sometimes appear against an \"AmazonEC2\" AWS Product column but are\n actually billed under AWSDataTransfer), the tool falls back to the inferred match and flags\n service_mismatch on that region's result rather than reporting no match.\n\n Some usage-type suffixes are shared by multiple distinct billable products (e.g. ELB's\n \"LCUUsage\" suffix matches Application/Network/Gateway load balancer pricing alike; RDS's\n \"InstanceUsage:\" suffix matches every database engine on that instance type). When\n that happens the affected region is reported under ambiguous_in (NOT in\n all_regions_sorted/cheapest_price/most_expensive_price — an ambiguous multi-product match\n is never silently resolved to \"cheapest\"), with every candidate row listed under\n alternate_matches. Pass operation and/or product_family — the same columns a CUR export\n carries alongside the usage-type/SKU column — to resolve it: e.g. for an Application Load\n Balancer LCU usage-type, product_family=\"Load Balancer-Application\" picks the correct row\n out of the Application/Network/Gateway alternatives.\n\n Regions with no catalog entry for the resolved suffix are reported in no_mapping_in\n (checked, not found) — this is distinct from errors_in (the catalog fetch itself failed)\n and from ambiguous_in (matched, but more than one product row and not yet resolved).\n Known limitation: compound inter-region/wavelength data-transfer SKUs with two region-\n shaped tokens (e.g. \"USE1WL1ATL1-CAN1-AWS-Out-Bytes\") are not fully resolved by the\n single-prefix-strip model; these produce a warning rather than a silently wrong match.\n\n Args:\n provider: Cloud provider — only \"aws\" is supported (raw usage-type SKUs are an AWS\n CUR concept with no GCP/Azure equivalent).\n sku: The raw usage-type/SKU string exactly as it appears in the CUR export.\n service: Optional AWS servicecode hint (e.g. \"AmazonEC2\", \"AWSELB\", \"AmazonRDS\",\n \"AmazonDynamoDB\", \"AmazonElastiCache\", \"AWSDataTransfer\"). If omitted, it is\n inferred from the usage-type pattern.\n regions: List of AWS region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"]. Required,\n max 30.\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n operation: Optional disambiguating hint — the AWS product \"operation\" attribute (e.g.\n \"CreateDBInstance:0021\" identifies Aurora PostgreSQL among RDS engines on\n the same instance type), matched case-insensitively. Use this when a region\n comes back in ambiguous_in.\n product_family: Optional disambiguating hint — the AWS top-level \"productFamily\" (e.g.\n \"Load Balancer-Application\" for an ALB vs NLB/GLB), matched\n case-insensitively. Use this when a region comes back in ambiguous_in.\n\n Examples:\n {\"sku\": \"CAN1-BoxUsage:r5a.8xlarge\", \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n {\"sku\": \"CAN1-AWS-Out-Bytes\", \"service\": \"AmazonEC2\", \"regions\": [\"ca-central-1\"]}\n {\"sku\": \"CAN1-LCUUsage\", \"service\": \"AWSELB\", \"product_family\": \"Load Balancer-Application\",\n \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n ", + "description": "\n Resolve a raw AWS usage-type/SKU string \u2014 exactly as it appears in a Cost & Usage Report\n (CUR) export, e.g. \"CAN1-BoxUsage:r5a.8xlarge\" \u2014 to a price, across one or more regions.\n\n Use this instead of get_price/compare_prices when you have a raw billing export line item\n (a \"UsageType\" or \"SKU\" column value) and need to reconcile it against current public\n pricing, rather than starting from a known resource_type/domain spec. This tool strips the\n region-prefix token from the usage-type string (e.g. \"CAN1-\", \"EU-\", or no prefix at all\n for us-east-1) to get a region-independent suffix, then matches that suffix against each\n target region's pricing catalog.\n\n If service is omitted, the AWS servicecode is inferred from the usage-type pattern (e.g.\n \"BoxUsage:\" implies AmazonEC2, \"LCUUsage\" implies AWSELB) \u2014 service_source in the response\n indicates \"explicit\" or \"inferred\". If a supplied service hint finds no match but the\n inferred servicecode does (real CUR data isn't always internally consistent \u2014 e.g. data-\n transfer usage types sometimes appear against an \"AmazonEC2\" AWS Product column but are\n actually billed under AWSDataTransfer), the tool falls back to the inferred match and flags\n service_mismatch on that region's result rather than reporting no match.\n\n Some usage-type suffixes are shared by multiple distinct billable products (e.g. ELB's\n \"LCUUsage\" suffix matches Application/Network/Gateway load balancer pricing alike; RDS's\n \"InstanceUsage:\" suffix matches every database engine on that instance type). When\n that happens the affected region is reported under ambiguous_in (NOT in\n all_regions_sorted/cheapest_price/most_expensive_price \u2014 an ambiguous multi-product match\n is never silently resolved to \"cheapest\"), with every candidate row listed under\n alternate_matches. Pass operation and/or product_family \u2014 the same columns a CUR export\n carries alongside the usage-type/SKU column \u2014 to resolve it: e.g. for an Application Load\n Balancer LCU usage-type, product_family=\"Load Balancer-Application\" picks the correct row\n out of the Application/Network/Gateway alternatives.\n\n Regions with no catalog entry for the resolved suffix are reported in no_mapping_in\n (checked, not found) \u2014 this is distinct from errors_in (the catalog fetch itself failed)\n and from ambiguous_in (matched, but more than one product row and not yet resolved).\n Known limitation: compound inter-region/wavelength data-transfer SKUs with two region-\n shaped tokens (e.g. \"USE1WL1ATL1-CAN1-AWS-Out-Bytes\") are not fully resolved by the\n single-prefix-strip model; these produce a warning rather than a silently wrong match.\n\n Args:\n provider: Cloud provider \u2014 only \"aws\" is supported (raw usage-type SKUs are an AWS\n CUR concept with no GCP/Azure equivalent).\n sku: The raw usage-type/SKU string exactly as it appears in the CUR export.\n service: Optional AWS servicecode hint (e.g. \"AmazonEC2\", \"AWSELB\", \"AmazonRDS\",\n \"AmazonDynamoDB\", \"AmazonElastiCache\", \"AWSDataTransfer\"). If omitted, it is\n inferred from the usage-type pattern.\n regions: List of AWS region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"]. Required,\n max 30.\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n operation: Optional disambiguating hint \u2014 the AWS product \"operation\" attribute (e.g.\n \"CreateDBInstance:0021\" identifies Aurora PostgreSQL among RDS engines on\n the same instance type), matched case-insensitively. Use this when a region\n comes back in ambiguous_in.\n product_family: Optional disambiguating hint \u2014 the AWS top-level \"productFamily\" (e.g.\n \"Load Balancer-Application\" for an ALB vs NLB/GLB), matched\n case-insensitively. Use this when a region comes back in ambiguous_in.\n\n Examples:\n {\"sku\": \"CAN1-BoxUsage:r5a.8xlarge\", \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n {\"sku\": \"CAN1-AWS-Out-Bytes\", \"service\": \"AmazonEC2\", \"regions\": [\"ca-central-1\"]}\n {\"sku\": \"CAN1-LCUUsage\", \"service\": \"AWSELB\", \"product_family\": \"Load Balancer-Application\",\n \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n ", "inputSchema": { "properties": { "baseline_region": { @@ -163,7 +163,7 @@ }, { "name": "get_prices_by_sku", - "description": "\n Batch form of get_price_by_sku: resolve many raw AWS usage-type/SKU strings — each exactly\n as it appears in a Cost & Usage Report (CUR) export — against the same set of target\n regions in one call.\n\n Use this to reconcile many CUR line items at once (e.g. every distinct usage-type/SKU in a\n monthly export) instead of issuing one get_price_by_sku call per SKU. Each sku is resolved\n independently via the same logic get_price_by_sku uses, so per-region ambiguous_in/\n no_mapping_in/errors_in bucketing and baseline_region deltas all apply per sku exactly as\n they would in a standalone get_price_by_sku call — this tool only adds the batching and\n aggregation layer on top.\n\n service/operation/product_family hints are not supported here (they are inherently\n per-sku, and different SKUs in a batch usually resolve to different services) — the AWS\n servicecode is inferred per sku from its usage-type pattern. If a particular sku needs a\n hint to resolve an ambiguous_in entry, follow up with a single get_price_by_sku call for\n that sku, passing operation/product_family.\n\n Each successfully-processed sku appears in \"results\", in the same order as the input skus\n list (NOT re-sorted by price — distinct SKUs commonly price in different units, e.g.\n per-hour vs per-GB vs per-request, that are not meaningfully comparable). A sku that fails\n outright (e.g. an empty string, or a usage-type pattern no service could be inferred for)\n is instead reported in the top-level \"errors\" map, keyed by that sku string, with\n message/status/retryable fields mirroring get_prices_batch's per-item error shape.\n\n Args:\n provider: Cloud provider — only \"aws\" is supported (raw usage-type SKUs are an AWS\n CUR concept with no GCP/Azure equivalent).\n skus: List of raw usage-type/SKU strings, each exactly as it appears in the CUR\n export. Required, max 25.\n regions: List of AWS region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"]. Required,\n max 30 (applies to every sku).\n baseline_region: Optional region for delta comparison, applied to every sku,\n e.g. \"us-east-1\".\n\n Examples:\n {\"skus\": [\"CAN1-BoxUsage:r5a.8xlarge\", \"USW2-BoxUsage:m5.large\"], \"regions\": [\"us-east-1\", \"ca-central-1\"]}\n ", + "description": "\n Batch form of get_price_by_sku: resolve many raw AWS usage-type/SKU strings \u2014 each exactly\n as it appears in a Cost & Usage Report (CUR) export \u2014 against the same set of target\n regions in one call.\n\n Use this to reconcile many CUR line items at once (e.g. every distinct usage-type/SKU in a\n monthly export) instead of issuing one get_price_by_sku call per SKU. Each sku is resolved\n independently via the same logic get_price_by_sku uses, so per-region ambiguous_in/\n no_mapping_in/errors_in bucketing and baseline_region deltas all apply per sku exactly as\n they would in a standalone get_price_by_sku call \u2014 this tool only adds the batching and\n aggregation layer on top.\n\n service/operation/product_family hints are not supported here (they are inherently\n per-sku, and different SKUs in a batch usually resolve to different services) \u2014 the AWS\n servicecode is inferred per sku from its usage-type pattern. If a particular sku needs a\n hint to resolve an ambiguous_in entry, follow up with a single get_price_by_sku call for\n that sku, passing operation/product_family.\n\n Each successfully-processed sku appears in \"results\", in the same order as the input skus\n list (NOT re-sorted by price \u2014 distinct SKUs commonly price in different units, e.g.\n per-hour vs per-GB vs per-request, that are not meaningfully comparable). A sku that fails\n outright (e.g. an empty string, or a usage-type pattern no service could be inferred for)\n is instead reported in the top-level \"errors\" map, keyed by that sku string, with\n message/status/retryable fields mirroring get_prices_batch's per-item error shape.\n\n Args:\n provider: Cloud provider \u2014 only \"aws\" is supported (raw usage-type SKUs are an AWS\n CUR concept with no GCP/Azure equivalent).\n skus: List of raw usage-type/SKU strings, each exactly as it appears in the CUR\n export. Required, max 25.\n regions: List of AWS region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"]. Required,\n max 30 (applies to every sku).\n baseline_region: Optional region for delta comparison, applied to every sku,\n e.g. \"us-east-1\".\n\n Examples:\n {\"skus\": [\"CAN1-BoxUsage:r5a.8xlarge\", \"USW2-BoxUsage:m5.large\"], \"regions\": [\"us-east-1\", \"ca-central-1\"]}\n ", "inputSchema": { "properties": { "provider": { @@ -216,7 +216,7 @@ }, { "name": "get_discount_summary", - "description": "\n Return a summary of all active cloud discounts for the authenticated account.\n\n For AWS: active Savings Plans (type, commitment $/hr, utilization %) and\n active Reserved Instances (instance type, count, payment type, days remaining),\n plus Cost Explorer utilization for the previous month.\n\n Requires credentials and OCC_AWS_ENABLE_COST_EXPLORER=true for AWS.\n\n Args:\n provider: Cloud provider — \"aws\" (GCP CUD support coming later)\n ", + "description": "\n Return a summary of all active cloud discounts for the authenticated account.\n\n For AWS: active Savings Plans (type, commitment $/hr, utilization %) and\n active Reserved Instances (instance type, count, payment type, days remaining),\n plus Cost Explorer utilization for the previous month.\n\n Requires credentials and OCC_AWS_ENABLE_COST_EXPLORER=true for AWS.\n\n Args:\n provider: Cloud provider \u2014 \"aws\" (GCP CUD support coming later)\n ", "inputSchema": { "properties": { "provider": { @@ -271,7 +271,7 @@ }, { "name": "list_regions", - "description": "\n List all regions where a cloud service is available for the given provider.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\"\n domain: Domain filter — \"compute\" (default), \"storage\", \"database\"\n ", + "description": "\n List all regions where a cloud service is available for the given provider.\n\n Args:\n provider: Cloud provider \u2014 \"aws\", \"gcp\", or \"azure\"\n domain: Domain filter \u2014 \"compute\" (default), \"storage\", \"database\"\n ", "inputSchema": { "properties": { "provider": { @@ -298,7 +298,7 @@ }, { "name": "list_instance_types", - "description": "\n List available compute instance types matching the given filters.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\"\n region: Region code, e.g. \"us-east-1\" (AWS), \"us-central1\" (GCP), \"eastus\" (Azure)\n family: Instance family prefix filter, e.g. \"m5\" (AWS), \"n2\" (GCP)\n min_vcpu: Minimum vCPU count filter\n min_memory_gb: Minimum memory in GB filter\n gpu: If True, only return GPU-enabled instance types\n ", + "description": "\n List available compute instance types matching the given filters.\n\n Args:\n provider: Cloud provider \u2014 \"aws\", \"gcp\", or \"azure\"\n region: Region code, e.g. \"us-east-1\" (AWS), \"us-central1\" (GCP), \"eastus\" (Azure)\n family: Instance family prefix filter, e.g. \"m5\" (AWS), \"n2\" (GCP)\n min_vcpu: Minimum vCPU count filter\n min_memory_gb: Minimum memory in GB filter\n gpu: If True, only return GPU-enabled instance types\n ", "inputSchema": { "properties": { "provider": { @@ -365,7 +365,7 @@ }, { "name": "describe_catalog", - "description": "\n Discover what each provider supports and how to call get_price.\n\n - No args → full support matrix across all configured providers.\n - provider only → all domains/services for that provider.\n - provider + domain [+ service] → targeted guidance with required_fields,\n supported_terms, filter_hints, and a ready-to-use example_invocation\n you can pass directly to get_price.\n\n Use this before get_price when unsure of exact field names or values.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\". Empty = all providers.\n domain: Domain — \"compute\", \"storage\", \"database\", \"ai\", \"container\",\n \"serverless\", \"analytics\", \"network\", \"observability\". Empty = all.\n service: Service — e.g. \"bedrock\", \"rds\", \"gke\", \"bigquery\". Empty = all.\n ", + "description": "\n Discover what each provider supports and how to call get_price.\n\n - No args \u2192 full support matrix across all configured providers.\n - provider only \u2192 all domains/services for that provider.\n - provider + domain [+ service] \u2192 targeted guidance with required_fields,\n supported_terms, filter_hints, and a ready-to-use example_invocation\n you can pass directly to get_price.\n\n Use this before get_price when unsure of exact field names or values.\n\n Args:\n provider: Cloud provider \u2014 \"aws\", \"gcp\", or \"azure\". Empty = all providers.\n domain: Domain \u2014 \"compute\", \"storage\", \"database\", \"ai\", \"container\",\n \"serverless\", \"analytics\", \"network\", \"observability\". Empty = all.\n service: Service \u2014 e.g. \"bedrock\", \"rds\", \"gke\", \"bigquery\". Empty = all.\n ", "inputSchema": { "properties": { "provider": { @@ -395,7 +395,7 @@ }, { "name": "find_cheapest_region", - "description": "\n Find the cheapest region for any cloud service.\n\n Queries pricing concurrently across regions and returns results sorted cheapest\n first, with the price delta between cheapest and most expensive regions.\n\n Args:\n spec: PricingSpec dict (same as get_price). The region field is overridden\n for each comparison — pass any region in the spec.\n regions: List of region codes to compare. Omit for major regions (faster).\n Pass [\"all\"] to search every available region (slow on first run without cache).\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n ", + "description": "\n Find the cheapest region for any cloud service.\n\n Queries pricing concurrently across regions and returns results sorted cheapest\n first, with the price delta between cheapest and most expensive regions.\n\n Args:\n spec: PricingSpec dict (same as get_price). The region field is overridden\n for each comparison \u2014 pass any region in the spec.\n regions: List of region codes to compare. Omit for major regions (faster).\n Pass [\"all\"] to search every available region (slow on first run without cache).\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n ", "inputSchema": { "properties": { "spec": { @@ -438,7 +438,7 @@ }, { "name": "find_available_regions", - "description": "\n Find all regions where a specific service/instance type is available, cheapest first.\n\n All fields must be nested under \"spec\" — do not pass provider/domain/resource_type\n etc. as top-level arguments. Example call:\n {\"spec\": {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\"}}\n\n Args:\n spec: PricingSpec dict (same as get_price). The region field is overridden\n per comparison — pass any region in the spec.\n regions: Region codes to check. Omit for major regions.\n Pass [\"all\"] to search every available region.\n baseline_region: Optional region for delta comparison.\n ", + "description": "\n Find all regions where a specific service/instance type is available, cheapest first.\n\n All fields must be nested under \"spec\" \u2014 do not pass provider/domain/resource_type\n etc. as top-level arguments. Example call:\n {\"spec\": {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\"}}\n\n Args:\n spec: PricingSpec dict (same as get_price). The region field is overridden\n per comparison \u2014 pass any region in the spec.\n regions: Region codes to check. Omit for major regions.\n Pass [\"all\"] to search every available region.\n baseline_region: Optional region for delta comparison.\n ", "inputSchema": { "properties": { "spec": { @@ -495,7 +495,7 @@ }, { "name": "warm_cache", - "description": "\n Pre-populate the pricing cache for a provider before a large sweep (e.g. a\n multi-region compare_prices or get_prices_batch call), so that sweep hits a warm\n cache instead of paying fetch latency on every combination.\n\n Resolves each requested service to its catalog example_invocation (the same data\n describe_catalog returns) and fans the resulting spec x region combinations out\n concurrently, mirroring the compare_prices/get_prices_batch fan-out pattern.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\"\n regions: List of region codes to warm, e.g. [\"us-east-1\", \"eu-west-1\"]\n services: Optional list of service names or describe_catalog keys, e.g.\n [\"ec2\", \"rds\", \"compute/fargate\"]. Omit to warm every service the\n provider's catalog has an example invocation for.\n ", + "description": "\n Pre-populate the pricing cache for a provider before a large sweep (e.g. a\n multi-region compare_prices or get_prices_batch call), so that sweep hits a warm\n cache instead of paying fetch latency on every combination.\n\n Resolves each requested service to its catalog example_invocation (the same data\n describe_catalog returns) and fans the resulting spec x region combinations out\n concurrently, mirroring the compare_prices/get_prices_batch fan-out pattern.\n\n Args:\n provider: Cloud provider \u2014 \"aws\", \"gcp\", or \"azure\"\n regions: List of region codes to warm, e.g. [\"us-east-1\", \"eu-west-1\"]\n services: Optional list of service names or describe_catalog keys, e.g.\n [\"ec2\", \"rds\", \"compute/fargate\"]. Omit to warm every service the\n provider's catalog has an example invocation for.\n ", "inputSchema": { "properties": { "provider": { @@ -532,7 +532,7 @@ }, { "name": "estimate_bom", - "description": "\n Use this tool for total infrastructure cost, TCO, monthly spend for a multi-resource\n stack, or cost comparison between architectures.\n\n Handles compute + storage + database + AI together in a single call — do NOT call\n get_price individually for multi-resource questions; use this tool instead.\n\n Returns per-item and total monthly/annual costs with real public pricing data,\n plus a not_included list of supplementary costs (egress, load balancers, monitoring).\n These are SUPPLEMENTARY — only price them if the user asked for TCO; for most\n questions just note 'additional costs may apply'.\n\n Each item should be a PricingSpec dict PLUS a quantity field:\n - provider: \"aws\" | \"gcp\" | \"azure\"\n - domain: \"compute\" | \"storage\" | \"database\" | \"ai\" | ...\n - region: region code\n - quantity: number of units (default 1)\n - hours_per_month: hours/month for compute (default 730 = always-on)\n - description: optional label for this line item\n Plus domain-specific fields (see get_price or describe_catalog for details).\n\n Examples:\n Compute + database + storage on AWS:\n [\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\", \"quantity\": 3},\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r6g.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"},\n {\"provider\": \"aws\", \"domain\": \"storage\", \"storage_type\": \"gp3\", \"size_gb\": 500, \"region\": \"us-east-1\"}\n ]\n\n Mixed cloud:\n [\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"quantity\": 2},\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\", \"quantity\": 1}\n ]\n ", + "description": "\n Use this tool for total infrastructure cost, TCO, monthly spend for a multi-resource\n stack, or cost comparison between architectures.\n\n Handles compute + storage + database + AI together in a single call \u2014 do NOT call\n get_price individually for multi-resource questions; use this tool instead.\n\n Returns per-item and total monthly/annual costs with real public pricing data,\n plus a not_included list of supplementary costs (egress, load balancers, monitoring).\n These are SUPPLEMENTARY \u2014 only price them if the user asked for TCO; for most\n questions just note 'additional costs may apply'.\n\n Each item should be a PricingSpec dict PLUS a quantity field:\n - provider: \"aws\" | \"gcp\" | \"azure\"\n - domain: \"compute\" | \"storage\" | \"database\" | \"ai\" | ...\n - region: region code\n - quantity: number of units (default 1)\n - hours_per_month: hours/month for compute (default 730 = always-on)\n - description: optional label for this line item\n Plus domain-specific fields (see get_price or describe_catalog for details).\n\n An item may instead be a raw-SKU dict (AWS-only): {\"sku\": \"...\",\n \"region\": \"us-east-1\", \"quantity\": 3} \u2014 same CUR usage-type/SKU\n string get_price_by_sku resolves, optionally with service/operation/\n product_family hints to disambiguate.\n\n Examples:\n Compute + database + storage on AWS:\n [\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\", \"quantity\": 3},\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r6g.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"},\n {\"provider\": \"aws\", \"domain\": \"storage\", \"storage_type\": \"gp3\", \"size_gb\": 500, \"region\": \"us-east-1\"}\n ]\n\n Mixed cloud:\n [\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"quantity\": 2},\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\", \"quantity\": 1}\n ]\n ", "inputSchema": { "properties": { "items": { @@ -541,7 +541,8 @@ "type": "object" }, "title": "Items", - "type": "array" + "type": "array", + "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, AWS-only) \u2014 see tool description." } }, "required": [ @@ -558,7 +559,7 @@ }, { "name": "estimate_unit_economics", - "description": "\n Estimate per-unit economics (cost per user, per request, per transaction) given\n a Bill of Materials and expected monthly usage volume.\n\n Args:\n items: Same format as estimate_bom — list of cloud resource PricingSpec dicts\n plus quantity field. See estimate_bom for full item format.\n units_per_month: Monthly volume being measured (e.g. 10000 users)\n unit_label: What the unit represents — \"user\", \"request\", \"transaction\", etc.\n ", + "description": "\n Estimate per-unit economics (cost per user, per request, per transaction) given\n a Bill of Materials and expected monthly usage volume.\n\n Args:\n items: Same format as estimate_bom \u2014 list of cloud resource PricingSpec dicts\n plus quantity field. See estimate_bom for full item format.\n units_per_month: Monthly volume being measured (e.g. 10000 users)\n unit_label: What the unit represents \u2014 \"user\", \"request\", \"transaction\", etc.\n ", "inputSchema": { "properties": { "items": { @@ -594,7 +595,7 @@ }, { "name": "compare_bom", - "description": "Price a multi-service workload across multiple cloud providers simultaneously and return a side-by-side cost comparison. Use this when the user wants to compare total costs across AWS, GCP, and/or Azure for the same infrastructure.\n\nOUTPUT FORMAT — aggregate totals only: for each workload key, storage capacity, provisioned IOPS, and provisioned throughput costs are summed into ONE number in the breakdown map. This tool does NOT return separate line items for storage $, IOPS $, and throughput $. If the user asks for a cost breakdown with storage capacity, provisioned IOPS, and provisioned throughput as separate line items per disk, use estimate_bom instead — it returns one row per price component.\n\nStorage: accepts abstract tiers (\"ssd\" → gp3/pd-ssd/premium-ssd, \"hdd\" → sc1/pd-standard/standard-hdd) or provider-specific types (gp3, io2, sc1, pd-ssd, pd-extreme, hyperdisk-extreme, etc.) with iops and throughput_mbps for IOPS pricing. Use compare_bom when a provider-vs-provider total-cost summary is sufficient.\n\nReturns per-provider totals keyed by pricing term, a breakdown map (workload_key → aggregate monthly $), committed vs on-demand savings, and any supplementary costs not included in the estimate.\n\nThe workload is described in cloud-agnostic terms (vcpus, memory_gb, storage_gb) — the tool selects the closest equivalent instance type per provider automatically.\n\nArgs:\n providers: Which providers to compare — [\"aws\", \"gcp\", \"azure\"] (default: all three).\n region_preference: Region tier — \"us\" (default), \"eu\", \"apac\".\n workload: Map of logical name → resource spec. Each spec needs 'type' (compute/storage/database/cache) plus vcpus, memory_gb, quantity, etc.\n terms: Pricing terms — default [\"on_demand\", \"reserved_1yr\"]. Term translation is automatic: reserved_1yr maps to cud_1yr for GCP.\n\nExample:\n workload: {\n \"web_servers\": {\"type\": \"compute\", \"vcpus\": 4, \"memory_gb\": 16, \"quantity\": 3},\n \"database\": {\"type\": \"database\", \"vcpus\": 8, \"memory_gb\": 32},\n \"storage\": {\"type\": \"storage\", \"storage_gb\": 500, \"storage_type\": \"ssd\"}\n }\n\n Multi-disk storage (gp3/io2 vs pd-ssd/pd-extreme):\n providers:[\"aws\",\"gcp\"], workload:{\"p_a\":{\"type\":\"storage\",\"storage_gb\":10000,\"storage_type\":\"gp3\",\"iops\":3000},\"p_c\":{\"type\":\"storage\",\"storage_gb\":500,\"storage_type\":\"io2\",\"iops\":64000}}", + "description": "Price a multi-service workload across multiple cloud providers simultaneously and return a side-by-side cost comparison. Use this when the user wants to compare total costs across AWS, GCP, and/or Azure for the same infrastructure.\n\nOUTPUT FORMAT \u2014 aggregate totals only: for each workload key, storage capacity, provisioned IOPS, and provisioned throughput costs are summed into ONE number in the breakdown map. This tool does NOT return separate line items for storage $, IOPS $, and throughput $. If the user asks for a cost breakdown with storage capacity, provisioned IOPS, and provisioned throughput as separate line items per disk, use estimate_bom instead \u2014 it returns one row per price component.\n\nStorage: accepts abstract tiers (\"ssd\" \u2192 gp3/pd-ssd/premium-ssd, \"hdd\" \u2192 sc1/pd-standard/standard-hdd) or provider-specific types (gp3, io2, sc1, pd-ssd, pd-extreme, hyperdisk-extreme, etc.) with iops and throughput_mbps for IOPS pricing. Use compare_bom when a provider-vs-provider total-cost summary is sufficient.\n\nReturns per-provider totals keyed by pricing term, a breakdown map (workload_key \u2192 aggregate monthly $), committed vs on-demand savings, and any supplementary costs not included in the estimate.\n\nThe workload is described in cloud-agnostic terms (vcpus, memory_gb, storage_gb) \u2014 the tool selects the closest equivalent instance type per provider automatically.\n\nArgs:\n providers: Which providers to compare \u2014 [\"aws\", \"gcp\", \"azure\"] (default: all three).\n region_preference: Region tier \u2014 \"us\" (default), \"eu\", \"apac\".\n workload: Map of logical name \u2192 resource spec. Each spec needs 'type' (compute/storage/database/cache) plus vcpus, memory_gb, quantity, etc.\n terms: Pricing terms \u2014 default [\"on_demand\", \"reserved_1yr\"]. Term translation is automatic: reserved_1yr maps to cud_1yr for GCP.\n\nExample:\n workload: {\n \"web_servers\": {\"type\": \"compute\", \"vcpus\": 4, \"memory_gb\": 16, \"quantity\": 3},\n \"database\": {\"type\": \"database\", \"vcpus\": 8, \"memory_gb\": 32},\n \"storage\": {\"type\": \"storage\", \"storage_gb\": 500, \"storage_type\": \"ssd\"}\n }\n\n Multi-disk storage (gp3/io2 vs pd-ssd/pd-extreme):\n providers:[\"aws\",\"gcp\"], workload:{\"p_a\":{\"type\":\"storage\",\"storage_gb\":10000,\"storage_type\":\"gp3\",\"iops\":3000},\"p_c\":{\"type\":\"storage\",\"storage_gb\":500,\"storage_type\":\"io2\",\"iops\":64000}}", "inputSchema": { "properties": { "providers": { @@ -699,7 +700,7 @@ }, { "name": "get_coverage", - "description": "\n Report which domains/services this server actually covers, per provider.\n\n v1 scope: structural coverage from the catalog only — each domain is\n reported as \"catalog\" (with its known services) unless the provider\n has no entry for it at all. This does NOT fan out a live get_price call\n per region — whether a specific region's live price is a real catalog\n rate or a degraded fallback constant is only observable by calling\n get_price for that spec and checking its \"fallback\" field, since that\n is a live fetch outcome rather than a fixed property of the catalog.\n\n Use this to answer \"what does this server know about\" before trial-\n and-error against describe_catalog and individual get_price calls.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\". Empty = all\n configured providers.\n ", + "description": "\n Report which domains/services this server actually covers, per provider.\n\n v1 scope: structural coverage from the catalog only \u2014 each domain is\n reported as \"catalog\" (with its known services) unless the provider\n has no entry for it at all. This does NOT fan out a live get_price call\n per region \u2014 whether a specific region's live price is a real catalog\n rate or a degraded fallback constant is only observable by calling\n get_price for that spec and checking its \"fallback\" field, since that\n is a live fetch outcome rather than a fixed property of the catalog.\n\n Use this to answer \"what does this server know about\" before trial-\n and-error against describe_catalog and individual get_price calls.\n\n Args:\n provider: Cloud provider \u2014 \"aws\", \"gcp\", or \"azure\". Empty = all\n configured providers.\n ", "inputSchema": { "properties": { "provider": { @@ -714,7 +715,7 @@ }, { "name": "compare_bom_regions", - "description": "\n Compare a Bill of Materials' total monthly cost across multiple AWS regions.\n\n v1 scope: AWS-only. Each item is an open PricingSpec dict, same shape as\n estimate_bom's items (provider, domain, resource_type/region/etc, plus\n quantity/hours_per_month/size_gb/description). The region field on each\n item is overridden per comparison — pass any region in the item dicts.\n Non-AWS items are reported once under \"not_supported\" rather than\n guessed or dropped silently; GCP/Azure support is tracked separately.\n\n Returns regions[] sorted cheapest-first, each with total_monthly, the\n resolved line_items, and any per-item errors. Optionally shows delta vs\n a baseline region.\n\n Args:\n items: List of PricingSpec dicts (same shape as estimate_bom).\n regions: List of AWS region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\"].\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n ", + "description": "\n Compare a Bill of Materials' total monthly cost across multiple AWS regions.\n\n v1 scope: AWS-only. Each item is an open PricingSpec dict, same shape as\n estimate_bom's items (provider, domain, resource_type/region/etc, plus\n quantity/hours_per_month/size_gb/description) \u2014 or a raw-SKU dict\n (sku, region, plus optional service/operation/product_family) for a CUR\n usage-type/SKU string, AWS-only. The region field on each item is\n overridden per comparison \u2014 pass any region in the item dicts.\n Weighting and a providers filter are not supported yet. Non-AWS items\n are reported once under \"not_supported\" rather than guessed or dropped\n silently; GCP/Azure support is tracked separately.\n\n Returns regions[] sorted cheapest-first, each with total_monthly, the\n resolved line_items, and any per-item errors. Optionally shows delta vs\n a baseline region.\n\n Args:\n items: List of PricingSpec dicts (same shape as estimate_bom) \u2014 or\n raw-SKU dicts (sku, region, plus optional service/operation/\n product_family), AWS-only. See estimate_bom for full item format.\n regions: List of AWS region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\"].\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n ", "inputSchema": { "properties": { "items": { @@ -723,7 +724,8 @@ "type": "object" }, "title": "Items", - "type": "array" + "type": "array", + "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, AWS-only) \u2014 see tool description." }, "regions": { "items": { From 0958385d1b6c3bfd34a7c0ba6962faafa2855881 Mon Sep 17 00:00:00 2001 From: x7even <38901965+x7even@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:26:30 +0000 Subject: [PATCH 2/9] feat(gcp): add raw-SKU lookup parity with AWS (RC3-015, #35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends get_price_by_sku, get_prices_by_sku, and the raw-SKU branches of estimate_bom/compare_bom_regions to support provider="gcp", matching AWS's existing raw-SKU lookup. Hoists the shared lookup types/interface into a new internal/skulookup package so both providers implement one contract. GCP's region attribution follows a geoTaxonomy-first, serviceRegions-fallback rule (GLOBAL/REGIONAL/MULTI_REGIONAL), since restrictive serviceRegions lists on otherwise-global SKUs (observed in KMS) make plain serviceRegions matching unreliable on its own. Checked all 13 onboarded GCP services for MULTI_REGIONAL geoTaxonomy usage before generalizing Firestore's multi-region short-name parser — only Firestore needs it, so it stays Firestore-specific rather than being hoisted into a shared abstraction prematurely. Also fixes graduated tiered-rate billing math, region-name case sensitivity, a GCP catalog-fetch cache-stampede risk (singleflight coalescing), and removes dead code left behind by the provider-generic refactor, found via an 8-angle review pass over the initial implementation. --- .../internal/providers/aws/aws_network.go | 8 +- .../providers/aws/aws_savingsplans.go | 44 +- .../providers/aws/aws_savingsplans_test.go | 8 +- .../internal/providers/aws/aws_sku_lookup.go | 176 ++---- .../internal/providers/gcp/gcp.go | 97 ++- .../internal/providers/gcp/gcp_firestore.go | 16 +- .../internal/providers/gcp/gcp_sku_lookup.go | 594 ++++++++++++++++++ .../providers/gcp/gcp_sku_lookup_test.go | 434 +++++++++++++ .../internal/providers/gcp/testhooks.go | 31 + opencloudcosts-go/internal/server/server.go | 12 +- .../internal/skulookup/skulookup.go | 203 ++++++ opencloudcosts-go/internal/tools/bom.go | 129 +++- opencloudcosts-go/internal/tools/bom_test.go | 133 ++++ .../internal/tools/compare_bom.go | 14 +- .../internal/tools/compare_bom_regions.go | 93 ++- .../tools/compare_bom_regions_test.go | 74 ++- opencloudcosts-go/internal/tools/lookup.go | 7 + .../internal/tools/lookup_test.go | 106 ++++ .../internal/tools/search_pricing.go | 4 +- .../internal/tools/sku_lookup.go | 163 +++-- .../internal/tools/sku_lookup_test.go | 79 ++- .../internal/tools/spot_history.go | 4 +- opencloudcosts-go/schemas/tools-snapshot.json | 52 +- 23 files changed, 2142 insertions(+), 339 deletions(-) create mode 100644 opencloudcosts-go/internal/providers/gcp/gcp_sku_lookup.go create mode 100644 opencloudcosts-go/internal/providers/gcp/gcp_sku_lookup_test.go create mode 100644 opencloudcosts-go/internal/providers/gcp/testhooks.go create mode 100644 opencloudcosts-go/internal/skulookup/skulookup.go diff --git a/opencloudcosts-go/internal/providers/aws/aws_network.go b/opencloudcosts-go/internal/providers/aws/aws_network.go index baacf60..d33a469 100644 --- a/opencloudcosts-go/internal/providers/aws/aws_network.go +++ b/opencloudcosts-go/internal/providers/aws/aws_network.go @@ -620,8 +620,8 @@ func (p *Provider) GetALBPrice(ctx context.Context, region string) ([]models.Nor Provider: models.CloudProviderAWS, Service: "lb", SKUID: fmt.Sprintf("aws:alb:%s:hourly", region), ProductFamily: "Load Balancer-Application", Description: "Application Load Balancer hourly charge", - Region: region, - Attributes: map[string]string{"lb_type": "application", "billing_dimension": "hourly"}, + Region: region, + Attributes: map[string]string{"lb_type": "application", "billing_dimension": "hourly"}, PricingTerm: models.PricingTermOnDemand, PricePerUnit: hourlyPrice, Unit: models.PriceUnitPerHour, Currency: "USD", FetchedAt: &now, SourceURL: sourceURL, @@ -632,8 +632,8 @@ func (p *Provider) GetALBPrice(ctx context.Context, region string) ([]models.Nor Provider: models.CloudProviderAWS, Service: "lb", SKUID: fmt.Sprintf("aws:alb:%s:lcu", region), ProductFamily: "Load Balancer-Application", Description: "Application Load Balancer LCU-hour", - Region: region, - Attributes: map[string]string{"lb_type": "application", "billing_dimension": "lcu_hour"}, + Region: region, + Attributes: map[string]string{"lb_type": "application", "billing_dimension": "lcu_hour"}, PricingTerm: models.PricingTermOnDemand, PricePerUnit: lcuPrice, Unit: models.PriceUnitPerHour, Currency: "USD", FetchedAt: &now, SourceURL: sourceURL, diff --git a/opencloudcosts-go/internal/providers/aws/aws_savingsplans.go b/opencloudcosts-go/internal/providers/aws/aws_savingsplans.go index ab08a24..074c33d 100644 --- a/opencloudcosts-go/internal/providers/aws/aws_savingsplans.go +++ b/opencloudcosts-go/internal/providers/aws/aws_savingsplans.go @@ -82,20 +82,20 @@ type spLeaseLength struct { // spTerm represents one entry from the terms.savingsPlan[] array. type spTerm struct { - SKU string `json:"sku"` - Description string `json:"description"` - EffectiveDate string `json:"effectiveDate"` - LeaseContractLength spLeaseLength `json:"leaseContractLength"` - Rates []spRate `json:"rates"` + SKU string `json:"sku"` + Description string `json:"description"` + EffectiveDate string `json:"effectiveDate"` + LeaseContractLength spLeaseLength `json:"leaseContractLength"` + Rates []spRate `json:"rates"` } // spProductMeta holds the classification data extracted from a product entry. type spProductMeta struct { - spType string // "csp" or "isp" + spType string // "csp" or "isp" purchaseOption string - purchaseTerm string // "1yr" or "3yr" + purchaseTerm string // "1yr" or "3yr" instanceFamily string // ISP only (e.g. "m7gd" from attribute instanceType) - productFamily string + productFamily string } // spRateKey is the lookup key for the in-memory SP rate index. @@ -120,12 +120,12 @@ type spIndex = map[string]spIndexEntry // spIndexEntry holds the data stored per rate in the in-memory index. type spIndexEntry struct { - Price float64 `json:"price"` - DiscountedSku string `json:"discounted_sku"` - Currency string `json:"currency"` - EffectiveDate time.Time `json:"effective_date"` - SourceURL string `json:"source_url"` - ProductFamily string `json:"product_family"` + Price float64 `json:"price"` + DiscountedSku string `json:"discounted_sku"` + Currency string `json:"currency"` + EffectiveDate time.Time `json:"effective_date"` + SourceURL string `json:"source_url"` + ProductFamily string `json:"product_family"` } // -------------------------------------------------------------------------- @@ -580,12 +580,12 @@ func (p *Provider) GetSavingsPlanPrice( // Build the SP NormalizedPrice. spAttrs := map[string]string{ - "sp_type": spType, + "sp_type": spType, "commitment_years": strconv.Itoa(years), - "payment_option": paymentOption, - "instance_type": instanceType, - "os": os, - "operation": operation, + "payment_option": paymentOption, + "instance_type": instanceType, + "os": os, + "operation": operation, } if spType == "isp" && instanceType != "" { // Extract instance family for informational purposes. @@ -626,10 +626,10 @@ func (p *Provider) GetSavingsPlanPrice( // Build breakdown. breakdown := map[string]any{ - "sp_type": spType, + "sp_type": spType, "commitment_years": years, - "payment_option": paymentOption, - "sp_rate": spRate, + "payment_option": paymentOption, + "sp_rate": spRate, "edp_note": "EDP is a confidential negotiated rate not available via public API. " + "Supply edp_discount_pct (0.0-1.0) to calculate your effective rate. " + "Market range: ~5% at $1M/yr commitment to ~20% at $50M+/yr. " + diff --git a/opencloudcosts-go/internal/providers/aws/aws_savingsplans_test.go b/opencloudcosts-go/internal/providers/aws/aws_savingsplans_test.go index 11fdc08..59bf9c8 100644 --- a/opencloudcosts-go/internal/providers/aws/aws_savingsplans_test.go +++ b/opencloudcosts-go/internal/providers/aws/aws_savingsplans_test.go @@ -416,9 +416,9 @@ func TestEDPAdjustment_ReducesRate(t *testing.T) { Region: "us-east-1", Term: models.PricingTermComputeSP, }, - ResourceType: "m5.xlarge", - OS: "Linux", - PaymentOption: &payOpt, + ResourceType: "m5.xlarge", + OS: "Linux", + PaymentOption: &payOpt, CommitmentYears: &years, EDPDiscountPct: &edpPct, } @@ -434,7 +434,7 @@ func TestEDPAdjustment_ReducesRate(t *testing.T) { t.Fatalf("expected EDP contracted price, got empty") } - spRate := result.PublicPrices[0].PricePerUnit // 0.141 + spRate := result.PublicPrices[0].PricePerUnit // 0.141 edpRate := result.ContractedPrices[0].PricePerUnit // 0.141 * 0.9 = 0.1269 const wantSP = 0.141 diff --git a/opencloudcosts-go/internal/providers/aws/aws_sku_lookup.go b/opencloudcosts-go/internal/providers/aws/aws_sku_lookup.go index be5a5a4..c4a97f4 100644 --- a/opencloudcosts-go/internal/providers/aws/aws_sku_lookup.go +++ b/opencloudcosts-go/internal/providers/aws/aws_sku_lookup.go @@ -41,6 +41,7 @@ import ( "time" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/models" + "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/skulookup" ) // -------------------------------------------------------------------------- @@ -217,15 +218,21 @@ func isChinaPartitionRegion(region string) bool { // switch on when building a structured JSON error response (mirroring how // the rest of this codebase distinguishes error kinds, e.g. "not_supported" // vs "not_configured" in tools/lookup.go). +// +// These were originally declared locally here; they now alias the canonical +// definitions in internal/skulookup so a second provider (GCP) can share them +// without importing this package. Every existing call site that spells them +// as awsprovider.SKUErrSKURequired, etc. continues to compile unchanged — see +// internal/skulookup's package doc for why. const ( - SKUErrUnsupportedProvider = "unsupported_provider" - SKUErrSKURequired = "sku_required" - SKUErrSKUTooLong = "sku_too_long" - SKUErrRegionsRequired = "regions_required" - SKUErrTooManyRegions = "too_many_regions" - SKUErrInvalidService = "invalid_service" - SKUErrServiceUndeterminable = "service_undeterminable" - SKUErrHintTooLong = "hint_too_long" + SKUErrUnsupportedProvider = skulookup.SKUErrUnsupportedProvider + SKUErrSKURequired = skulookup.SKUErrSKURequired + SKUErrSKUTooLong = skulookup.SKUErrSKUTooLong + SKUErrRegionsRequired = skulookup.SKUErrRegionsRequired + SKUErrTooManyRegions = skulookup.SKUErrTooManyRegions + SKUErrInvalidService = skulookup.SKUErrInvalidService + SKUErrServiceUndeterminable = skulookup.SKUErrServiceUndeterminable + SKUErrHintTooLong = skulookup.SKUErrHintTooLong ) // maxSKULength bounds the raw sku string. Real AWS usage-type strings are at @@ -247,16 +254,8 @@ const maxSKULength = 1024 // is. const maxHintLength = 256 -// SKULookupError is returned for request-level failures that apply to the -// whole lookup (bad input, unsupported provider) as opposed to a single -// region's result, which is instead represented as a non-error entry inside -// SKULookupResult.Regions (see that type's docs for why). -type SKULookupError struct { - Code string - Message string -} - -func (e *SKULookupError) Error() string { return e.Message } +// SKULookupError aliases skulookup.SKULookupError — see that package's docs. +type SKULookupError = skulookup.SKULookupError // maxSKULookupRegions caps the regions list. Each requested region can // trigger a full multi-hundred-MB offer-file download per candidate service @@ -467,98 +466,17 @@ func fetchSKUCatalog(ctx context.Context, p *Provider, service, region string) ( // Public result types // -------------------------------------------------------------------------- -// SKULookupRegionResult is the per-region outcome of a SKU lookup. Exactly -// one of the following holds, mirroring how the rest of this codebase -// distinguishes "we looked and found nothing" from "we couldn't look": -// - len(Prices) > 0: a match was found; ServiceUsed names the AWS -// servicecode whose catalog contained it. -// - NoMapping == true: every candidate service's catalog was fetched -// successfully for this region, but no product row's usage-type suffix -// matched the input SKU's suffix. This is the explicit "no mapping -// found" result the caller needs to distinguish "priced but not in this -// region" (or "not modeled by AWS at all") from a transient failure. -// - Error != "": the region code or China-partition check failed, or every -// candidate service's catalog fetch itself failed (e.g. network/HTTP -// error) — we don't actually know whether a match exists. -type SKULookupRegionResult struct { - Region string `json:"region"` - - // ServiceUsed is the AWS servicecode whose catalog produced the match. - // Only set when len(Prices) > 0. - ServiceUsed string `json:"service_used,omitempty"` - - // ServiceMismatch is true when ServiceUsed differs from the caller's - // explicit service hint — i.e. the hint's catalog had no match, but the - // inferred-service fallback catalog did. See LookupSKUAcrossRegions docs. - ServiceMismatch bool `json:"service_mismatch,omitempty"` - - // Prices holds the resolved candidate row(s) for this region. In the - // common case this is a single row. When multiple product rows share the - // same stripped usage-type suffix (e.g. an EC2 BoxUsage suffix matches - // Linux, Windows, and RHEL rows, or Shared vs Dedicated tenancy rows), - // resolveSKUCandidates first narrows to the codebase's established - // canonical-default attributes (see canonicalDefaultAttrs) before this is - // populated. If that narrowing still leaves more than one row — i.e. the - // usage-type suffix genuinely does not disambiguate them (the clearest - // case: RDS databaseEngine, which no usage-type string encodes) — all - // remaining candidates are kept here and Ambiguous is set, rather than - // silently picking one (e.g. the cheapest) and reporting it as *the* - // price. - Prices []models.NormalizedPrice `json:"prices,omitempty"` - - // Ambiguous is true when Prices contains more than one row that - // resolveSKUCandidates could not narrow down to a single canonical match - // — the caller must disambiguate using Prices[i].Attributes / - // Description / SKUID rather than trusting a single "the" price. - Ambiguous bool `json:"ambiguous,omitempty"` - - // HintStatus explains *why* Ambiguous is what it is, one of the - // HintStatus* constants: "resolved_by_hint" (an operation/product_family - // hint narrowed Prices to exactly one row — Ambiguous is false), - // "hint_no_match" (a hint was supplied but matched none of the - // candidates — fails closed, Prices holds the original unfiltered set, - // Ambiguous is true), "hint_ambiguous" (a hint was supplied and matched - // more than one candidate even after canonical-default narrowing — - // Ambiguous is true), or "no_hint_supplied" (no hint was given; ordinary - // canonicalDefaultAttrs narrowing applied, which may or may not have - // resolved to one row). Only meaningful when len(Prices) > 1 originally - // applied (i.e. there was something to disambiguate at all). - HintStatus string `json:"hint_status,omitempty"` - - NoMapping bool `json:"no_mapping,omitempty"` - - Error string `json:"error,omitempty"` - - // AttemptedServices lists the AWS servicecodes searched for this region, - // in search order, for diagnostic/debugging purposes. - AttemptedServices []string `json:"attempted_services,omitempty"` -} +// SKULookupRegionResult aliases skulookup.SKULookupRegionResult — see that +// package's docs. It was originally declared locally here; AWS never sets +// its additive Tiered field (see skulookup's docs for why — AWS's usage-type +// suffix model does not surface tiered rate schedules through this path). +type SKULookupRegionResult = skulookup.SKULookupRegionResult -// SKULookupResult is the full result of a get_price_by_sku lookup: the -// canonicalized form of the input SKU, service-resolution provenance, and -// one SKULookupRegionResult per requested region (in the same order as the -// input regions list — the tool-handler layer is responsible for any -// cheapest-first sorting, mirroring how compare_prices sorts after fan-out). -type SKULookupResult struct { - SKU string `json:"sku"` - - // UsageTypePrefix is the stripped region-prefix token ("CAN1", "EU", "") - // and UsageTypeSuffix is the region-independent remainder used for - // cross-region matching. See stripUsageTypePrefix. - UsageTypePrefix string `json:"usage_type_prefix"` - UsageTypeSuffix string `json:"usage_type_suffix"` - - ServiceHint string `json:"service_hint,omitempty"` - InferredService string `json:"inferred_service,omitempty"` - // ServiceSource is "explicit" when ServiceHint was supplied and used as - // the primary candidate, or "inferred" when no hint was given and - // InferredService was derived from the usage-type pattern. - ServiceSource string `json:"service_source"` - - Warnings []string `json:"warnings,omitempty"` - - Regions []SKULookupRegionResult `json:"regions"` -} +// SKULookupResult aliases skulookup.SKULookupResult — see that package's +// docs. UsageTypePrefix/UsageTypeSuffix (the stripped region-prefix token and +// region-independent remainder, see stripUsageTypePrefix) are AWS-only +// concepts populated only by this file. +type SKULookupResult = skulookup.SKULookupResult // -------------------------------------------------------------------------- // LookupSKUAcrossRegions — main entry point @@ -783,22 +701,14 @@ var canonicalDefaultAttrs = map[string]string{ // Hint-resolution status codes, surfaced on SKULookupRegionResult.HintStatus // so a caller can tell *why* a region is still ambiguous rather than just // seeing "ambiguous: true" again. See resolveSKUCandidates. +// +// These alias the canonical definitions in internal/skulookup — see that +// package's docs. const ( - // HintStatusNoHint means neither operationHint nor productFamilyHint was - // supplied — resolution fell back to the existing canonicalDefaultAttrs - // narrowing (or, if that also failed to narrow, plain ambiguity). - HintStatusNoHint = "no_hint_supplied" - // HintStatusResolved means a supplied hint narrowed the candidates to - // exactly one row. - HintStatusResolved = "resolved_by_hint" - // HintStatusNoMatch means a hint was supplied but matched zero candidate - // rows — resolution fails closed: the original unfiltered candidate set - // is returned, still ambiguous, rather than silently ignoring the hint. - HintStatusNoMatch = "hint_no_match" - // HintStatusAmbiguous means a hint was supplied and matched more than one - // row (canonical-default narrowing was then tried on that hint-filtered - // subset and still could not get to exactly one). - HintStatusAmbiguous = "hint_ambiguous" + HintStatusNoHint = skulookup.HintStatusNoHint + HintStatusResolved = skulookup.HintStatusResolved + HintStatusNoMatch = skulookup.HintStatusNoMatch + HintStatusAmbiguous = skulookup.HintStatusAmbiguous ) // resolveSKUCandidates narrows prices (all rows sharing one stripped @@ -1003,3 +913,21 @@ func (p *Provider) lookupSKUInRegion( rr.NoMapping = true return rr } + +// -------------------------------------------------------------------------- +// skulookup.SKULookupProvider conformance +// -------------------------------------------------------------------------- + +// LookupSKUAcrossRegionsGeneric adapts LookupSKUAcrossRegions to the +// provider-agnostic skulookup.SKULookupProvider interface, so the +// tool-handler layer (internal/tools) can resolve AWS and GCP raw-SKU lookups +// through one generic code path instead of hardcoding *Provider. It does not +// replace or change the behavior of LookupSKUAcrossRegions — it is a +// different method name delegating to the exact same logic, with +// providerName hardcoded to "aws" (this method only ever makes sense for an +// AWS *Provider instance). +func (p *Provider) LookupSKUAcrossRegionsGeneric( + ctx context.Context, sku string, regions []string, serviceHint string, hint skulookup.SKUHint, +) (*skulookup.SKULookupResult, error) { + return p.LookupSKUAcrossRegions(ctx, "aws", sku, serviceHint, regions, hint.OperationHint, hint.ProductFamilyHint) +} diff --git a/opencloudcosts-go/internal/providers/gcp/gcp.go b/opencloudcosts-go/internal/providers/gcp/gcp.go index ea8f708..225886a 100644 --- a/opencloudcosts-go/internal/providers/gcp/gcp.go +++ b/opencloudcosts-go/internal/providers/gcp/gcp.go @@ -19,6 +19,8 @@ import ( "strings" "time" + "golang.org/x/sync/singleflight" + "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/cache" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/config" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/models" @@ -92,10 +94,38 @@ func (p *Provider) MajorRegions() []string { // Catalog HTTP helpers // -------------------------------------------------------------------------- +// gcpSKUFetchGroup coalesces concurrent fetchSKUs calls for the same +// serviceID into a single in-flight execution (shared cache-hit unmarshal or +// cache-miss network fetch), the same way AWS's skuCatalogCache uses +// sync.Once per (service, region) key. Without this, callers that fan out +// concurrently over the same candidate service IDs — the raw-SKU-lookup +// region fan-out (compare_bom_regions.go's per-region goroutines) and SKU +// fan-out (get_prices_by_sku's per-SKU goroutines) — can each independently +// re-fetch (on a cold cache) or re-unmarshal (on a warm cache) the same +// multi-thousand-row catalog at the same time instead of sharing one result. +// It is package-level (not per-Provider) since serviceID alone is already +// process-globally unique for this purpose, mirroring skuCatalogCache's own +// process-lifetime scope. +var gcpSKUFetchGroup singleflight.Group + // fetchSKUs returns all SKUs for the given GCP service ID as raw maps. // Each SKU is a map[string]any matching the JSON shape from the Billing Catalog API. -// Results are cached using the metadata TTL. +// Results are cached using the metadata TTL. Concurrent calls for the same +// serviceID are coalesced via gcpSKUFetchGroup — see its doc comment. func (p *Provider) fetchSKUs(ctx context.Context, serviceID string) ([]map[string]any, error) { + v, err, _ := gcpSKUFetchGroup.Do(serviceID, func() (any, error) { + return p.fetchSKUsUncoalesced(ctx, serviceID) + }) + if err != nil { + return nil, err + } + return v.([]map[string]any), nil +} + +// fetchSKUsUncoalesced is fetchSKUs' actual body, called through +// gcpSKUFetchGroup so concurrent callers for the same serviceID share one +// execution instead of each independently hitting the cache/network. +func (p *Provider) fetchSKUsUncoalesced(ctx context.Context, serviceID string) ([]map[string]any, error) { cacheKey := "gcp:skus:" + serviceID if raw, ok := p.cache.GetMetadata(cacheKey); ok { var skus []map[string]any @@ -383,15 +413,14 @@ type tierRate struct { price float64 } -// skuTierList parses every tiered unit price out of a raw GCP SKU -// (map[string]any as returned by the Billing Catalog API), sorted ascending -// by startUsageAmount (ties keep their original relative order). It is the -// single shared JSON-unwrap step behind skuPrice (below — first zero-start -// tier), skuPaidPrice (gcp_ai.go — first tier with startUsageAmount > 0), and -// skuAllTierRates (gcp_dns.go — every tier, for SKUs with more than two -// tiers); previously each reimplemented this same -// pricingInfo->pricingExpression->tieredRates unwrap independently. -func skuTierList(sku map[string]any) []tierRate { +// gcpPricingExpression unwraps a raw GCP SKU's +// pricingInfo[0].pricingExpression object — the single shared JSON-unwrap +// step behind every reader of that object (skuTierList below, and +// gcpSKUUnit in gcp_sku_lookup.go), so a future change to the +// pricingInfo/pricingExpression JSON shape (or a bugfix to the unwrap logic) +// only needs to happen in one place instead of silently drifting between +// independently-reimplemented copies. +func gcpPricingExpression(sku map[string]any) map[string]any { pi, _ := sku["pricingInfo"].([]any) if len(pi) == 0 { return nil @@ -401,6 +430,19 @@ func skuTierList(sku map[string]any) []tierRate { return nil } expr, _ := pe["pricingExpression"].(map[string]any) + return expr +} + +// skuTierList parses every tiered unit price out of a raw GCP SKU +// (map[string]any as returned by the Billing Catalog API), sorted ascending +// by startUsageAmount (ties keep their original relative order). It is the +// single shared JSON-unwrap step behind skuPrice (below — first zero-start +// tier), skuPaidPrice (gcp_ai.go — first tier with startUsageAmount > 0), and +// skuAllTierRates (gcp_dns.go — every tier, for SKUs with more than two +// tiers); previously each reimplemented this same +// pricingInfo->pricingExpression->tieredRates unwrap independently. +func skuTierList(sku map[string]any) []tierRate { + expr := gcpPricingExpression(sku) if expr == nil { return nil } @@ -439,15 +481,17 @@ func skuPrice(sku map[string]any) float64 { return 0 } -// newGlobalScopedPrice builds a global-scoped (Region="global", -// Attributes["scope"]="global") NormalizedPrice with Provider=GCP, -// PricingTerm=OnDemand, and Currency=USD already filled in — the fields -// shared by every region-invariant GCP pricing domain (Cloud DNS, Cloud -// Pub/Sub, ...). Domain-specific constructors like newDNSPrice/newPubSubPrice -// wrap this with their fixed Service/ProductFamily so call sites keep their -// existing, domain-named entry point. -func newGlobalScopedPrice(service, productFamily, skuID, description string, pricePerUnit float64, unit models.PriceUnit, attrs map[string]string) *models.NormalizedPrice { - price := &models.NormalizedPrice{ +// newGCPBasePrice builds the region-less NormalizedPrice base shape shared by +// every GCP price constructor in this package (Provider=GCP, +// PricingTerm=OnDemand, Currency=USD, plus the SKU-identifying fields) — +// previously hand-built independently by newGlobalScopedPrice (below), +// newFirestorePrice (gcp_firestore.go), and gcpBuildMatchedPrices +// (gcp_sku_lookup.go), which risked a future change to this common shape +// (e.g. a new field, or a Currency/PricingTerm default fix) being applied to +// some of those call sites but missed in others. Callers fill in Region (and +// any scope stamping) themselves, since that varies per constructor. +func newGCPBasePrice(service, productFamily, skuID, description string, pricePerUnit float64, unit models.PriceUnit, attrs map[string]string) models.NormalizedPrice { + return models.NormalizedPrice{ Provider: models.CloudProviderGCP, Service: service, SKUID: skuID, @@ -459,8 +503,19 @@ func newGlobalScopedPrice(service, productFamily, skuID, description string, pri Currency: "USD", Attributes: attrs, } - stampGlobalScope(price) - return price +} + +// newGlobalScopedPrice builds a global-scoped (Region="global", +// Attributes["scope"]="global") NormalizedPrice with Provider=GCP, +// PricingTerm=OnDemand, and Currency=USD already filled in — the fields +// shared by every region-invariant GCP pricing domain (Cloud DNS, Cloud +// Pub/Sub, ...). Domain-specific constructors like newDNSPrice/newPubSubPrice +// wrap this with their fixed Service/ProductFamily so call sites keep their +// existing, domain-named entry point. +func newGlobalScopedPrice(service, productFamily, skuID, description string, pricePerUnit float64, unit models.PriceUnit, attrs map[string]string) *models.NormalizedPrice { + price := newGCPBasePrice(service, productFamily, skuID, description, pricePerUnit, unit, attrs) + stampGlobalScope(&price) + return &price } // isGlobalSKU reports whether a raw GCP SKU's geoTaxonomy defensively diff --git a/opencloudcosts-go/internal/providers/gcp/gcp_firestore.go b/opencloudcosts-go/internal/providers/gcp/gcp_firestore.go index 002e185..2a17c80 100644 --- a/opencloudcosts-go/internal/providers/gcp/gcp_firestore.go +++ b/opencloudcosts-go/internal/providers/gcp/gcp_firestore.go @@ -526,19 +526,9 @@ func resolveFirestoreRates(live map[string]firestoreRates, region string) (rates // requested region (NOT global scope — see file header) and the fields // common to every Firestore line item. func newFirestorePrice(region, skuID, description string, pricePerUnit float64, unit models.PriceUnit, attrs map[string]string) models.NormalizedPrice { - return models.NormalizedPrice{ - Provider: models.CloudProviderGCP, - Service: "firestore", - SKUID: skuID, - ProductFamily: "Cloud Firestore", - Description: description, - Region: region, - PricingTerm: models.PricingTermOnDemand, - PricePerUnit: pricePerUnit, - Unit: unit, - Currency: "USD", - Attributes: attrs, - } + price := newGCPBasePrice("firestore", "Cloud Firestore", skuID, description, pricePerUnit, unit, attrs) + price.Region = region + return price } // priceFirestore returns Cloud Firestore pricing for the given diff --git a/opencloudcosts-go/internal/providers/gcp/gcp_sku_lookup.go b/opencloudcosts-go/internal/providers/gcp/gcp_sku_lookup.go new file mode 100644 index 0000000..d3ce166 --- /dev/null +++ b/opencloudcosts-go/internal/providers/gcp/gcp_sku_lookup.go @@ -0,0 +1,594 @@ +// gcp_sku_lookup.go implements get_price_by_sku's GCP counterpart to AWS's +// raw usage-type/SKU lookup (internal/providers/aws/aws_sku_lookup.go): given +// a raw GCP Cloud Billing Catalog "skuId" string (e.g. "0055-9F63-3A4D"), +// find its price in a list of target regions. +// +// Unlike AWS's usage-type/SKU strings, a GCP skuId does not encode a region +// at all — the same opaque ID is either region-invariant (GLOBAL), scoped to +// exactly one region (REGIONAL), or scoped to a named multi-region +// (MULTI_REGIONAL), as declared on the SKU's own geoTaxonomy field. There is +// also no service-inference heuristic analogous to AWS's usage-type pattern +// matching: a skuId does not by itself hint which of the 13 onboarded GCP +// service catalogs it lives in, so an omitted service hint means scanning +// every one of them (bounded concurrency, see below) rather than guessing. +// +// REGION-ATTRIBUTION RULE (geoTaxonomy-first, serviceRegions-fallback): +// This file's per-region matching logic is grounded in two prior, live- +// verified findings elsewhere in this package, not invented fresh here: +// - gcp_kms.go: every in-scope Cloud KMS SKU has geoTaxonomy.type=="GLOBAL" +// and is deliberately reported as Region="global" regardless of the +// region the caller asked about, bypassing serviceRegions entirely — a +// precedent this file follows for any matched SKU whose geoTaxonomy.type +// is GLOBAL (or absent, historically treated as GLOBAL by isGlobalSKU). +// - gcp_firestore.go: Cloud Firestore's serviceRegions is the literal +// ["global"] on every SKU regardless of true scope, making serviceRegions +// membership useless for that service — geoTaxonomy.type/regions is the +// only usable signal, and MULTI_REGIONAL SKUs (nam5/nam7's overlapping +// constituent-region lists) require description-based short-name parsing +// (skuFirestoreMultiRegion) rather than trusting geoTaxonomy.regions +// directly. +// +// A dedicated research pass (see issue RC3-015 planning notes) confirmed +// Firestore is the ONLY onboarded service with evidence of MULTI_REGIONAL +// SKUs; every other service either uses literal serviceRegions membership or +// is pure GLOBAL. So this file's MULTI_REGIONAL case reuses +// skuFirestoreMultiRegion as-is rather than generalizing it — see that +// research's recommendation for why a generalized parser is not (yet) +// justified. If a future live catalog check surfaces a non-Firestore +// MULTI_REGIONAL SKU with an unusable serviceRegions, lifting +// skuFirestoreMultiRegion's substring loop into a shared +// skuMultiRegionShortName(descLower, knownNames) helper is a small, +// contained refactor, not a redesign. +// +// Because of all this, region attribution here is, in priority order: +// 1. geoTaxonomy.type == "GLOBAL": matches every requested region. +// 2. geoTaxonomy.type == "REGIONAL": matches iff geoTaxonomy.regions is +// exactly one entry equal to the requested region string. +// 3. geoTaxonomy.type == "MULTI_REGIONAL": matches iff +// skuFirestoreMultiRegion(descLower) equals the requested region string +// (Firestore's own convention — callers pass "nam5"/"nam7"/"eur3" as the +// region). +// 4. geoTaxonomy absent/unrecognized: fall back to the pre-Firestore +// serviceRegions + skuMatchesRegion membership check used by every other +// onboarded GCP domain file. +// +// All methods are on *Provider defined in gcp.go (Part 1). +package gcp + +import ( + "context" + "fmt" + "log/slog" + "strings" + "sync" + + "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/models" + "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/skulookup" +) + +// gcpSKUMaxLength bounds the raw skuId string. Mirrors aws.maxSKULength's +// rationale (real skuId values are short hex-hyphenated tokens, far under +// this cap; the cap exists only to reject pathological/abusive input before +// it's echoed into error messages, not to constrain any real skuId shape). +// aws.maxSKULength is unexported, so this is a separate, identically-valued +// constant rather than a shared one. +const gcpSKUMaxLength = 1024 + +// gcpSKUMaxLookupRegions bounds the regions list, mirroring +// aws.maxSKULookupRegions's rationale: each candidate service catalog is +// fetched once regardless of region count (fetchSKUs is not region-scoped +// for GCP), but an unbounded regions list still means unbounded per-region +// work building the response, so it is capped defensively. +const gcpSKUMaxLookupRegions = 30 + +// gcpSKUFetchConcurrency bounds how many candidate service catalogs are +// fetched concurrently for one lookup. A cold-cache, no-service-hint lookup +// has up to 13 candidate services (every onboarded GCP domain), each a +// paginated Cloud Billing Catalog fetch — fetching all 13 serially risks +// exceeding the default 60s per-tool-call context.WithTimeout +// (internal/config/config.go RequestTimeout, applied in +// internal/server/server.go), so this bounds worst-case latency the same way +// LookupSKUAcrossRegions (aws_sku_lookup.go) bounds its own region fan-out +// with a semaphore. +const gcpSKUFetchConcurrency = 5 + +// -------------------------------------------------------------------------- +// Service-hint resolution +// -------------------------------------------------------------------------- + +// gcpSKULookupServiceOrder is the fixed, deterministic scan order for a +// no-service-hint lookup (every onboarded GCP service), and the canonical +// name used to report ServiceUsed/AttemptedServices in the response — chosen +// so a caller can plug ServiceUsed straight back in as service= on a +// follow-up call, mirroring how AWS's ServiceUsed (a servicecode) is itself +// a valid service= input. +var gcpSKULookupServiceOrder = []string{ + "compute", "gcs", "cloudsql", "gke", "memorystore", "kms", "dns", + "firestore", "pubsub", "vertex", "bigquery", "monitoring", "armor", +} + +// gcpSKULookupServiceIDs maps a canonical service name (and a couple of +// obvious aliases) to its GCP Cloud Billing Catalog service ID. Every +// canonical (non-alias) key has a corresponding entry in +// gcpSKULookupServiceOrder. +var gcpSKULookupServiceIDs = map[string]string{ + "compute": computeServiceID, + "gcs": gcsServiceID, + "cloudstorage": gcsServiceID, // alias + "cloudsql": cloudSQLServiceID, + "gke": gkeServiceID, + "memorystore": memorystoreServiceID, + "kms": kmsServiceID, + "cloudkms": kmsServiceID, // alias + "dns": dnsServiceID, + "clouddns": dnsServiceID, // alias + "firestore": firestoreServiceID, + "pubsub": pubsubServiceID, + "vertex": vertexServiceID, + "bigquery": bigqueryServiceID, + "monitoring": cloudMonitoringServiceID, + "armor": cloudArmorServiceID, +} + +// gcpSKULookupServiceIDToName reverse-maps a service ID back to its +// canonical name (built once from gcpSKULookupServiceOrder, so it only ever +// contains canonical names, never aliases). +var gcpSKULookupServiceIDToName = func() map[string]string { + m := make(map[string]string, len(gcpSKULookupServiceOrder)) + for _, name := range gcpSKULookupServiceOrder { + m[gcpSKULookupServiceIDs[name]] = name + } + return m +}() + +// resolveGCPSKUServiceCandidates resolves serviceHint to the list of +// candidate service IDs to scan, in gcpSKULookupServiceOrder's order. An +// empty hint means "scan everything"; a non-empty, unrecognized hint is a +// validation error rather than silently falling back to a full scan. +func resolveGCPSKUServiceCandidates(serviceHint string) ([]string, *skulookup.SKULookupError) { + if serviceHint == "" { + ids := make([]string, len(gcpSKULookupServiceOrder)) + for i, name := range gcpSKULookupServiceOrder { + ids[i] = gcpSKULookupServiceIDs[name] + } + return ids, nil + } + id, ok := gcpSKULookupServiceIDs[strings.ToLower(serviceHint)] + if !ok { + return nil, &skulookup.SKULookupError{ + Code: skulookup.SKUErrInvalidService, + Message: fmt.Sprintf( + "service %q is not a recognized GCP service for raw-SKU lookup — known values: %s", + serviceHint, strings.Join(gcpSKULookupServiceOrder, ", "), + ), + } + } + return []string{id}, nil +} + +// gcpServiceIDsToNames converts a slice of raw GCP service IDs to their +// canonical names (see gcpSKULookupServiceIDToName), for building +// human-readable warnings/errors and the AttemptedServices field. An ID with +// no canonical name (should not occur — every candidate ID originates from +// gcpSKULookupServiceIDs) is passed through verbatim as a defensive +// fallback. +func gcpServiceIDsToNames(ids []string) []string { + names := make([]string, 0, len(ids)) + for _, id := range ids { + if n, ok := gcpSKULookupServiceIDToName[id]; ok { + names = append(names, n) + } else { + names = append(names, id) + } + } + return names +} + +// -------------------------------------------------------------------------- +// Region-attribution and price-extraction helpers +// -------------------------------------------------------------------------- + +// gcpSKUMatchesRequestedRegion reports whether a matched raw SKU applies to +// requestedRegion, per the geoTaxonomy-first/serviceRegions-fallback rule +// documented at the top of this file. regionType is returned alongside for +// the caller to distinguish the GLOBAL case (which needs stampGlobalScope +// applied to the resulting price) from the others. +func gcpSKUMatchesRequestedRegion(sku map[string]any, requestedRegion string) (matches bool, regionType string) { + // Region codes in the raw GCP Cloud Billing Catalog JSON (geoTaxonomy. + // regions, serviceRegions) and this package's own multi-region short + // names (firestoreMultiRegions) are always lowercase, but requestedRegion + // arrives here straight from the caller with no case normalization + // applied anywhere upstream (see sku_lookup.go/bom.go). Lowercase it once + // here, mirroring gcp_firestore.go's firestoreRegionKey / gcp_networking. + // go's strings.ToLower(region) precedent for exactly this comparison, so + // a caller passing e.g. "NAM5" or "US-CENTRAL1" still matches. + requestedRegion = strings.ToLower(requestedRegion) + regionType, geoRegions := skuGeoTaxonomy(sku) + switch regionType { + case "GLOBAL": + return true, regionType + case "REGIONAL": + return len(geoRegions) == 1 && geoRegions[0] == requestedRegion, regionType + case "MULTI_REGIONAL": + desc, _ := sku["description"].(string) + short := skuFirestoreMultiRegion(strings.ToLower(desc)) + if short == "" { + // Unlike Firestore's own fetchFirestoreRates (gcp_firestore.go), + // which slog.Warns and skips a MULTI_REGIONAL SKU whose + // description carries no recognized multi-region short name, + // this domain-agnostic path had no equivalent diagnostic — + // silently reporting NoMapping for every requested region with + // no way to distinguish "genuinely out of scope" from "we + // couldn't parse the multi-region name". Warn so this is at + // least visible/debuggable. + skuID, _ := sku["skuId"].(string) + slog.Warn("gcp raw-sku lookup: MULTI_REGIONAL SKU with no recognized multi-region name in description", + "sku_id", skuID, "description", desc) + return false, regionType + } + return short == requestedRegion, regionType + default: + // geoTaxonomy absent, or a type this codebase has never observed — + // fall back to plain serviceRegions membership, the pre-Firestore + // convention every other onboarded domain file uses. + regionsAny, _ := sku["serviceRegions"].([]any) + matches = skuMatchesRegion(regionsAny, requestedRegion) + if matches && serviceRegionsContainsGlobal(regionsAny) { + // isGlobalSKU's precedent (this file's own header comment) + // treats absent/unrecognized geoTaxonomy as GLOBAL whenever the + // SKU is otherwise region-invariant. skuMatchesRegion's match + // here can come from either a literal requestedRegion entry or + // the "global" sentinel; only the latter actually means + // region-invariant, so only report regionType="GLOBAL" (which + // triggers stampGlobalScope on the caller side) when + // serviceRegions itself contains "global" — not merely because + // requestedRegion happened to also be listed. + return true, "GLOBAL" + } + return matches, regionType + } +} + +// serviceRegionsContainsGlobal reports whether a SKU's raw serviceRegions +// slice contains the literal sentinel "global" (as opposed to matching only +// because it lists the specific requestedRegion). +func serviceRegionsContainsGlobal(regions []any) bool { + for _, r := range regions { + if s, _ := r.(string); s == "global" { + return true + } + } + return false +} + +// gcpSKUProductFamily extracts a matched SKU's category.resourceFamily (the +// GCP Cloud Billing Catalog field documented for this purpose — e.g. +// "Compute", "Storage", "ApplicationServices" — but never read by any +// existing per-domain file in this package, which only ever read +// category.resourceGroup/usageType for their own domain-specific narrowing; +// there is no established convention to reuse here). Falls back to +// category.resourceGroup (the field every other file does read) if +// resourceFamily is absent, since either is a reasonable best-effort +// "product family" label for a domain-agnostic lookup with no a priori +// knowledge of which is more meaningful for the matched service. +func gcpSKUProductFamily(sku map[string]any) string { + cat, _ := sku["category"].(map[string]any) + if cat == nil { + return "" + } + if rf, ok := cat["resourceFamily"].(string); ok && rf != "" { + return rf + } + if rg, ok := cat["resourceGroup"].(string); ok && rg != "" { + return rg + } + return "" +} + +// gcpSKUUnit maps a matched SKU's raw pricingInfo[0].pricingExpression. +// usageUnit code to the closest models.PriceUnit constant. This is +// deliberately best-effort/generic — unlike every existing per-domain file +// in this package, which hardcodes the correct unit because it already knows +// the domain (e.g. gcp_kms.go always knows a key-version rate is +// per-key-version-month), a domain-agnostic raw-SKU lookup has no a priori +// knowledge of which unit fits, only the raw GCP unit code string. Falls +// back to models.PriceUnitPerUnit for any code not in this (non-exhaustive) +// table. +func gcpSKUUnit(sku map[string]any) models.PriceUnit { + expr := gcpPricingExpression(sku) + if expr == nil { + return models.PriceUnitPerUnit + } + raw, _ := expr["usageUnit"].(string) + switch raw { + case "h": + return models.PriceUnitPerHour + case "mo": + return models.PriceUnitPerMonth + case "GiBy.mo": + return models.PriceUnitPerGBMonth + case "GiBy": + return models.PriceUnitPerGB + case "requests": + return models.PriceUnitPerRequest + case "count", "1": + return models.PriceUnitPerUnit + default: + return models.PriceUnitPerUnit + } +} + +// copyStringMap returns a shallow copy of m (nil in, nil out), so per-region +// NormalizedPrice clones built from one shared matched-SKU template never +// alias the same Attributes map (stampGlobalScope, called per matching +// region for a GLOBAL SKU, mutates its price's Attributes map in place). +func copyStringMap(m map[string]string) map[string]string { + if m == nil { + return nil + } + out := make(map[string]string, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +// gcpBuildMatchedPrices builds one models.NormalizedPrice per priced tier of +// a matched raw SKU (region-independent — Region is left unset; callers +// clone and set it per matching requested region). Returns a non-empty +// errMsg instead of prices when the matched SKU has no priceable rate at +// all, which the caller must surface as a region Error rather than a +// silently-zero price. +func gcpBuildMatchedPrices(sku map[string]any, serviceName string) (prices []models.NormalizedPrice, tiered bool, errMsg string) { + tiers := skuTierList(sku) + if len(tiers) == 0 { + skuID, _ := sku["skuId"].(string) + return nil, false, fmt.Sprintf( + "matched GCP SKU %q has no priceable rate (no tieredRates found in pricingInfo) — this is an anomaly, not a zero price", skuID) + } + skuID, _ := sku["skuId"].(string) + desc, _ := sku["description"].(string) + productFamily := gcpSKUProductFamily(sku) + unit := gcpSKUUnit(sku) + tiered = len(tiers) > 1 + + prices = make([]models.NormalizedPrice, 0, len(tiers)) + for _, t := range tiers { + var attrs map[string]string + if tiered { + attrs = map[string]string{"tier_start_usage": fmt.Sprintf("%g", t.start)} + } + prices = append(prices, newGCPBasePrice(serviceName, productFamily, skuID, desc, t.price, unit, attrs)) + } + return prices, tiered, "" +} + +// uniformRegionResults builds one skulookup.SKULookupRegionResult per region, +// each a copy of tmpl with only Region varying. Shared by +// LookupSKUAcrossRegionsGeneric's "incomplete scan" and "complete scan, no +// match" branches, which previously each independently allocated and looped +// to build an identical shape (differing only in whether Error or NoMapping +// was set) — a future field added to SKULookupRegionResult that must be +// populated uniformly is now only ever set in one place instead of two that +// could silently drift apart. +func uniformRegionResults(regions []string, tmpl skulookup.SKULookupRegionResult) []skulookup.SKULookupRegionResult { + out := make([]skulookup.SKULookupRegionResult, len(regions)) + for i, region := range regions { + rr := tmpl + rr.Region = region + out[i] = rr + } + return out +} + +// -------------------------------------------------------------------------- +// LookupSKUAcrossRegionsGeneric — skulookup.SKULookupProvider conformance +// -------------------------------------------------------------------------- + +// LookupSKUAcrossRegionsGeneric resolves the price of a raw GCP Cloud +// Billing Catalog skuId string in each of the given regions. hint is +// accepted for skulookup.SKULookupProvider interface conformance but is not +// used: GCP has no confirmed disambiguation axis analogous to AWS's +// operation/productFamily hints today — a matched skuId resolves to exactly +// one catalog row per service, so there is nothing here for +// OperationHint/ProductFamilyHint to narrow. (Open question carried over +// from planning: whether category.usageType could ever need to disambiguate +// two rows sharing one skuId — no evidence of that has been found; revisit +// if it ever is.) +func (p *Provider) LookupSKUAcrossRegionsGeneric( + ctx context.Context, sku string, regions []string, serviceHint string, hint skulookup.SKUHint, +) (*skulookup.SKULookupResult, error) { + _ = hint + + if sku == "" { + return nil, &skulookup.SKULookupError{Code: skulookup.SKUErrSKURequired, Message: "sku must not be empty"} + } + if len(sku) > gcpSKUMaxLength { + return nil, &skulookup.SKULookupError{ + Code: skulookup.SKUErrSKUTooLong, + Message: fmt.Sprintf( + "sku must be at most %d characters (got %d) — real GCP skuId values are far shorter", + gcpSKUMaxLength, len(sku)), + } + } + if len(regions) == 0 { + return nil, &skulookup.SKULookupError{ + Code: skulookup.SKUErrRegionsRequired, + Message: "regions must contain at least one GCP region code", + } + } + if len(regions) > gcpSKUMaxLookupRegions { + return nil, &skulookup.SKULookupError{ + Code: skulookup.SKUErrTooManyRegions, + Message: fmt.Sprintf( + "regions must contain at most %d entries (got %d)", gcpSKUMaxLookupRegions, len(regions)), + } + } + + candidateIDs, svcErr := resolveGCPSKUServiceCandidates(serviceHint) + if svcErr != nil { + return nil, svcErr + } + + result := &skulookup.SKULookupResult{ + SKU: sku, + ServiceHint: serviceHint, + } + if serviceHint != "" { + result.ServiceSource = "explicit" + } else { + // GCP has no AWS-style single-service inference heuristic from the + // skuId's own shape — an omitted hint means every onboarded service + // is scanned, not a guessed single candidate. + result.ServiceSource = "scanned_all" + } + + // Fan out the candidate service catalog fetches with bounded + // concurrency — see gcpSKUFetchConcurrency's doc for why. + type fetchOutcome struct { + serviceID string + skus []map[string]any + err error + } + outcomes := make([]fetchOutcome, len(candidateIDs)) + sem := make(chan struct{}, gcpSKUFetchConcurrency) + var wg sync.WaitGroup + for i, sid := range candidateIDs { + wg.Add(1) + go func(idx int, serviceID string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + skus, err := p.fetchSKUs(ctx, serviceID) + outcomes[idx] = fetchOutcome{serviceID: serviceID, skus: skus, err: err} + }(i, sid) + } + wg.Wait() + + // Scan every successfully-fetched service's SKU list to completion (never + // short-circuit on the first match) so a duplicate skuId within one + // service's catalog is always detected, and so every candidate's + // fetch-success/failure is known before deciding whether "no match" means + // a genuine no_mapping or an incomplete scan. + var matchedServiceID string + var matchedSKU map[string]any + var succeededIDs, failedIDs []string + var warnings []string + duplicateWarned := false + + for _, o := range outcomes { + if o.err != nil { + failedIDs = append(failedIDs, o.serviceID) + continue + } + succeededIDs = append(succeededIDs, o.serviceID) + + var localMatches int + var localFirst map[string]any + for _, s := range o.skus { + id, _ := s["skuId"].(string) + if id == sku { + localMatches++ + if localMatches == 1 { + localFirst = s + } + } + } + if localMatches > 1 && !duplicateWarned { + warnings = append(warnings, fmt.Sprintf( + "unexpected: %d catalog rows matched skuId %q within service %q — using the first row; "+ + "the assumed GCP skuId-uniqueness invariant may not hold", + localMatches, sku, o.serviceID)) + duplicateWarned = true + } + if localMatches > 0 { + if matchedSKU == nil { + matchedServiceID = o.serviceID + matchedSKU = localFirst + } else if o.serviceID != matchedServiceID { + // A different service ALSO matched this skuId — an asymmetry + // with the same-service duplicate case above, which does + // warn. Report it: silently keeping whichever service + // happened to be first in scan order, with no visibility + // into the collision, would be a real (if rare) mispricing + // risk if the assumed cross-service skuId-uniqueness + // invariant is ever violated. + warnings = append(warnings, fmt.Sprintf( + "unexpected: skuId %q also matched a catalog row in service %q; using the first-scanned "+ + "match from service %q — the assumed GCP skuId-uniqueness-across-services invariant may not hold", + sku, o.serviceID, matchedServiceID)) + } + } + } + + switch { + case matchedSKU != nil: + if len(failedIDs) > 0 { + warnings = append(warnings, fmt.Sprintf( + "a match was found in service %q, but the scan was not fully exhaustive: service(s) %v "+ + "could not be fetched and were not searched", + gcpSKULookupServiceIDToName[matchedServiceID], gcpServiceIDsToNames(failedIDs))) + } + + serviceName := gcpSKULookupServiceIDToName[matchedServiceID] + basePrices, tiered, buildErr := gcpBuildMatchedPrices(matchedSKU, serviceName) + + regionResults := make([]skulookup.SKULookupRegionResult, len(regions)) + for i, region := range regions { + rr := skulookup.SKULookupRegionResult{Region: region} + matches, regionType := gcpSKUMatchesRequestedRegion(matchedSKU, region) + switch { + case !matches: + // A matched skuId with a narrower geography than requested + // is expected/normal for a REGIONAL (or MULTI_REGIONAL) SKU — + // report it as this region's own no_mapping, not a whole- + // lookup failure, since other requested regions may still + // match. + rr.NoMapping = true + rr.AttemptedServices = []string{serviceName} + case buildErr != "": + rr.Error = buildErr + default: + prices := make([]models.NormalizedPrice, len(basePrices)) + for j, bp := range basePrices { + cp := bp + cp.Attributes = copyStringMap(bp.Attributes) + cp.Region = region + if regionType == "GLOBAL" { + stampGlobalScope(&cp) + } + prices[j] = cp + } + rr.ServiceUsed = serviceName + rr.Prices = prices + rr.Tiered = tiered + } + regionResults[i] = rr + } + result.Regions = regionResults + + case len(failedIDs) > 0: + // No match found, but the scan was incomplete — an incomplete scan + // is not evidence of absence, so this must NOT be reported as + // no_mapping. + attempted := gcpServiceIDsToNames(succeededIDs) + msg := fmt.Sprintf( + "could not determine whether sku %q exists: service(s) %v failed to fetch and could not be "+ + "searched; successfully searched: %v", sku, gcpServiceIDsToNames(failedIDs), attempted) + result.Regions = uniformRegionResults(regions, skulookup.SKULookupRegionResult{ + Error: msg, + AttemptedServices: attempted, + }) + + default: + // A genuine, complete scan found no match anywhere. + attempted := gcpServiceIDsToNames(succeededIDs) + result.Regions = uniformRegionResults(regions, skulookup.SKULookupRegionResult{ + NoMapping: true, + AttemptedServices: attempted, + }) + } + + result.Warnings = warnings + return result, nil +} diff --git a/opencloudcosts-go/internal/providers/gcp/gcp_sku_lookup_test.go b/opencloudcosts-go/internal/providers/gcp/gcp_sku_lookup_test.go new file mode 100644 index 0000000..a66798f --- /dev/null +++ b/opencloudcosts-go/internal/providers/gcp/gcp_sku_lookup_test.go @@ -0,0 +1,434 @@ +// gcp_sku_lookup_test.go tests LookupSKUAcrossRegionsGeneric (RC3-015), +// GCP's raw-skuId counterpart to AWS's get_price_by_sku lookup. Coverage +// focuses on the region-attribution priority rule (geoTaxonomy-first, +// serviceRegions-fallback) documented at the top of gcp_sku_lookup.go, plus +// the multi-service fan-out's error/no-match/duplicate-skuId handling. +package gcp + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/skulookup" +) + +// -------------------------------------------------------------------------- +// Fixture helpers +// -------------------------------------------------------------------------- + +// tierSpec is one (startUsageAmount, unitPrice) pair for lookupSKU's +// tieredRates. +type tierSpec struct { + start float64 + units string + nanos int +} + +// lookupSKU builds a raw GCP SKU map exposing every field +// LookupSKUAcrossRegionsGeneric's region-attribution and price-extraction +// logic reads: skuId, description, serviceRegions, geoTaxonomy (only set +// when geoType != ""), and one or more tieredRates. Unlike makeSKU +// (gcp_compute_test.go), which only builds a single flat-rate REGIONAL-ish +// fixture, this supports every geoTaxonomy shape this file's tests exercise. +func lookupSKU(skuID, desc string, serviceRegions []string, geoType string, geoRegions []string, tiers []tierSpec) map[string]any { + tieredRates := make([]any, 0, len(tiers)) + for _, t := range tiers { + tieredRates = append(tieredRates, map[string]any{ + "startUsageAmount": t.start, + "unitPrice": map[string]any{ + "units": t.units, + "nanos": float64(t.nanos), + }, + }) + } + regionsAny := make([]any, len(serviceRegions)) + for i, r := range serviceRegions { + regionsAny[i] = r + } + sku := map[string]any{ + "skuId": skuID, + "description": desc, + "serviceRegions": regionsAny, + "category": map[string]any{ + "resourceFamily": "Test", + "resourceGroup": "Test", + "usageType": "OnDemand", + }, + "pricingInfo": []any{ + map[string]any{ + "pricingExpression": map[string]any{ + "usageUnit": "h", + "tieredRates": tieredRates, + }, + }, + }, + } + if geoType != "" { + geoRegionsAny := make([]any, len(geoRegions)) + for i, r := range geoRegions { + geoRegionsAny[i] = r + } + sku["geoTaxonomy"] = map[string]any{ + "type": geoType, + "regions": geoRegionsAny, + } + } + return sku +} + +// newMultiServiceSKUServer builds a fake Cloud Billing Catalog server whose +// response depends on which service ID the request path names +// (/services/{serviceID}/skus), so tests can drive +// LookupSKUAcrossRegionsGeneric's multi-service fan-out (up to all 13 +// onboarded services for a no-hint lookup) with per-service fixtures. Any +// serviceID not present in byServiceID gets a clean empty-catalog 200 — +// callers only need to define handlers for the service(s) a given test cares +// about, not all 13. +func newMultiServiceSKUServer(t *testing.T, byServiceID map[string]http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) < 2 { + http.NotFound(w, r) + return + } + svcID := parts[1] + if h, ok := byServiceID[svcID]; ok { + h(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(skuResponse(nil)) + })) +} + +// jsonSKUsHandler returns an http.HandlerFunc serving a fixed SKU list as a +// 200 OK Cloud Billing Catalog page. +func jsonSKUsHandler(skus []map[string]any) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(skuResponse(skus)) + } +} + +// failingHandler always responds with HTTP 500, simulating a service whose +// catalog fetch fails mid-scan. +func failingHandler(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal error", http.StatusInternalServerError) +} + +// -------------------------------------------------------------------------- +// 1. GLOBAL geoTaxonomy beats a restrictive serviceRegions list (the KMS-bug +// regression — the single most important test in this file). +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_GlobalGeoTaxonomyBeatsRestrictiveServiceRegions(t *testing.T) { + sku := lookupSKU( + "SKU-GLOBAL", "Global key version rate", + []string{"europe-west1"}, // deliberately restrictive/wrong serviceRegions + "GLOBAL", nil, + []tierSpec{{start: 0, units: "0", nanos: 3_000_000}}, + ) + server := newMultiServiceSKUServer(t, map[string]http.HandlerFunc{ + kmsServiceID: jsonSKUsHandler([]map[string]any{sku}), + }) + defer server.Close() + p := newTestProvider(t, server) + + res, err := p.LookupSKUAcrossRegionsGeneric( + context.Background(), "SKU-GLOBAL", []string{"us-central1"}, "kms", skulookup.SKUHint{}) + if err != nil { + t.Fatalf("LookupSKUAcrossRegionsGeneric error: %v", err) + } + if len(res.Regions) != 1 { + t.Fatalf("expected 1 region result, got %d", len(res.Regions)) + } + rr := res.Regions[0] + // us-central1 is NOT in serviceRegions ([]string{"europe-west1"}) — a + // match here proves geoTaxonomy.type==GLOBAL is checked (and wins) before + // any serviceRegions membership fallback, exactly like the live Cloud + // KMS bug this regression test mirrors. + if rr.NoMapping || rr.Error != "" { + t.Fatalf("expected us-central1 to match via GLOBAL geoTaxonomy despite restrictive serviceRegions, got NoMapping=%v Error=%q", + rr.NoMapping, rr.Error) + } + if len(rr.Prices) != 1 { + t.Fatalf("expected 1 price, got %d", len(rr.Prices)) + } +} + +// -------------------------------------------------------------------------- +// 2. MULTI_REGIONAL: overlapping geoTaxonomy.regions must not cause a false +// match — only the description-based short name decides. +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_MultiRegionalNoCollision(t *testing.T) { + // nam5 and nam7 are documented (gcp_firestore.go) to share constituent + // regions in their geoTaxonomy.regions lists (e.g. both list + // "us-central1"). This SKU's description says "nam5"; requesting region + // "nam7" must NOT match even though the constituent-region lists overlap. + sku := lookupSKU( + "SKU-NAM5", "Cloud Firestore storage nam5", + []string{"global"}, // Firestore's serviceRegions is a useless literal "global" + "MULTI_REGIONAL", []string{"us-central1", "us-east1", "us-east4"}, + []tierSpec{{start: 0, units: "0", nanos: 180_000_000}}, + ) + server := newMultiServiceSKUServer(t, map[string]http.HandlerFunc{ + firestoreServiceID: jsonSKUsHandler([]map[string]any{sku}), + }) + defer server.Close() + p := newTestProvider(t, server) + + res, err := p.LookupSKUAcrossRegionsGeneric( + context.Background(), "SKU-NAM5", []string{"nam5", "nam7"}, "firestore", skulookup.SKUHint{}) + if err != nil { + t.Fatalf("LookupSKUAcrossRegionsGeneric error: %v", err) + } + if len(res.Regions) != 2 { + t.Fatalf("expected 2 region results, got %d", len(res.Regions)) + } + byRegion := map[string]skulookup.SKULookupRegionResult{} + for _, rr := range res.Regions { + byRegion[rr.Region] = rr + } + + nam5 := byRegion["nam5"] + if nam5.NoMapping || nam5.Error != "" || len(nam5.Prices) != 1 { + t.Errorf("expected nam5 to match its own SKU, got NoMapping=%v Error=%q Prices=%d", + nam5.NoMapping, nam5.Error, len(nam5.Prices)) + } + nam7 := byRegion["nam7"] + if !nam7.NoMapping { + t.Errorf("expected nam7 to NOT match a nam5-tagged SKU (overlapping constituent regions must not cause a false match), got NoMapping=%v Prices=%d", + nam7.NoMapping, len(nam7.Prices)) + } +} + +// -------------------------------------------------------------------------- +// 3. REGIONAL: matches only its exact single region. +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_RegionalExactMatchOnly(t *testing.T) { + sku := lookupSKU( + "SKU-REGIONAL", "Regional disk rate", + []string{"us-west1"}, + "REGIONAL", []string{"us-west1"}, + []tierSpec{{start: 0, units: "0", nanos: 40_000_000}}, + ) + server := newMultiServiceSKUServer(t, map[string]http.HandlerFunc{ + computeServiceID: jsonSKUsHandler([]map[string]any{sku}), + }) + defer server.Close() + p := newTestProvider(t, server) + + res, err := p.LookupSKUAcrossRegionsGeneric( + context.Background(), "SKU-REGIONAL", []string{"us-west1", "us-west2"}, "compute", skulookup.SKUHint{}) + if err != nil { + t.Fatalf("LookupSKUAcrossRegionsGeneric error: %v", err) + } + byRegion := map[string]skulookup.SKULookupRegionResult{} + for _, rr := range res.Regions { + byRegion[rr.Region] = rr + } + if m := byRegion["us-west1"]; m.NoMapping || len(m.Prices) != 1 { + t.Errorf("expected us-west1 (exact region) to match, got NoMapping=%v Prices=%d", m.NoMapping, len(m.Prices)) + } + if m := byRegion["us-west2"]; !m.NoMapping { + t.Errorf("expected us-west2 (different region) to NOT match a REGIONAL SKU scoped to us-west1, got NoMapping=%v", m.NoMapping) + } +} + +// -------------------------------------------------------------------------- +// 4. Tiered-rate SKU: 3+ ascending tiers, Tiered=true. +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_TieredRates(t *testing.T) { + sku := lookupSKU( + "SKU-TIERED", "Tiered storage rate", + []string{"us-central1"}, + "", nil, + []tierSpec{ + {start: 0, units: "0", nanos: 100_000_000}, + {start: 1000, units: "0", nanos: 80_000_000}, + {start: 5000, units: "0", nanos: 50_000_000}, + }, + ) + server := newMultiServiceSKUServer(t, map[string]http.HandlerFunc{ + gcsServiceID: jsonSKUsHandler([]map[string]any{sku}), + }) + defer server.Close() + p := newTestProvider(t, server) + + res, err := p.LookupSKUAcrossRegionsGeneric( + context.Background(), "SKU-TIERED", []string{"us-central1"}, "gcs", skulookup.SKUHint{}) + if err != nil { + t.Fatalf("LookupSKUAcrossRegionsGeneric error: %v", err) + } + rr := res.Regions[0] + if !rr.Tiered { + t.Fatalf("expected Tiered=true for a 3-tier SKU") + } + if len(rr.Prices) != 3 { + t.Fatalf("expected 3 tier prices, got %d", len(rr.Prices)) + } + // Ascending order by tier_start_usage, and the first tier's price used + // as the primary/default entry (rr.Prices[0]). + wantStarts := []string{"0", "1000", "5000"} + wantPrices := []float64{0.1, 0.08, 0.05} + for i, p := range rr.Prices { + if got := p.Attributes["tier_start_usage"]; got != wantStarts[i] { + t.Errorf("tier %d: tier_start_usage = %q, want %q", i, got, wantStarts[i]) + } + if abs(p.PricePerUnit-wantPrices[i]) > 1e-9 { + t.Errorf("tier %d: price = %v, want %v", i, p.PricePerUnit, wantPrices[i]) + } + } +} + +// -------------------------------------------------------------------------- +// 5. Mid-scan error: one candidate service fails, no match among the +// services that did succeed — must be reported as Error, not NoMapping. +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_MidScanErrorNotNoMapping(t *testing.T) { + server := newMultiServiceSKUServer(t, map[string]http.HandlerFunc{ + kmsServiceID: failingHandler, + }) + defer server.Close() + p := newTestProvider(t, server) + + // No service hint: scans all 13 onboarded services. kms 500s; every + // other service returns an empty (non-matching) catalog. + res, err := p.LookupSKUAcrossRegionsGeneric( + context.Background(), "SKU-NOT-FOUND", []string{"us-central1"}, "", skulookup.SKUHint{}) + if err != nil { + t.Fatalf("LookupSKUAcrossRegionsGeneric error: %v", err) + } + if len(res.Regions) != 1 { + t.Fatalf("expected 1 region result, got %d", len(res.Regions)) + } + rr := res.Regions[0] + if rr.NoMapping { + t.Errorf("expected an incomplete scan (kms failed) to NOT be reported as NoMapping") + } + if rr.Error == "" { + t.Errorf("expected a non-empty Error for an incomplete scan") + } + // AttemptedServices must reflect only the services that actually + // completed (12 of 13 — every onboarded service except kms). + if len(rr.AttemptedServices) != len(gcpSKULookupServiceOrder)-1 { + t.Errorf("expected AttemptedServices to list %d completed services, got %d: %v", + len(gcpSKULookupServiceOrder)-1, len(rr.AttemptedServices), rr.AttemptedServices) + } + for _, s := range rr.AttemptedServices { + if s == "kms" { + t.Errorf("AttemptedServices must not include the failed service %q, got %v", "kms", rr.AttemptedServices) + } + } +} + +// -------------------------------------------------------------------------- +// 6. Clean full-scan, no match anywhere: NoMapping=true, not Error. +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_CleanNoMatch(t *testing.T) { + server := newMultiServiceSKUServer(t, nil) // every service returns an empty catalog + defer server.Close() + p := newTestProvider(t, server) + + res, err := p.LookupSKUAcrossRegionsGeneric( + context.Background(), "SKU-NOWHERE", []string{"us-central1"}, "", skulookup.SKUHint{}) + if err != nil { + t.Fatalf("LookupSKUAcrossRegionsGeneric error: %v", err) + } + rr := res.Regions[0] + if !rr.NoMapping { + t.Errorf("expected a genuine complete-scan miss to be reported as NoMapping, got NoMapping=%v Error=%q", rr.NoMapping, rr.Error) + } + if rr.Error != "" { + t.Errorf("expected no Error for a clean no-match scan, got %q", rr.Error) + } + if len(rr.AttemptedServices) != len(gcpSKULookupServiceOrder) { + t.Errorf("expected AttemptedServices to list all %d onboarded services, got %d: %v", + len(gcpSKULookupServiceOrder), len(rr.AttemptedServices), rr.AttemptedServices) + } +} + +// -------------------------------------------------------------------------- +// 7. Invalid/unrecognized service hint. +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_InvalidServiceHint(t *testing.T) { + server := newMultiServiceSKUServer(t, nil) + defer server.Close() + p := newTestProvider(t, server) + + _, err := p.LookupSKUAcrossRegionsGeneric( + context.Background(), "SKU-ANY", []string{"us-central1"}, "not-a-real-service", skulookup.SKUHint{}) + if err == nil { + t.Fatalf("expected an error for an unrecognized service hint, got nil") + } + skuErr, ok := err.(*skulookup.SKULookupError) + if !ok { + t.Fatalf("expected *skulookup.SKULookupError, got %T: %v", err, err) + } + if skuErr.Code != skulookup.SKUErrInvalidService { + t.Errorf("Code = %q, want %q", skuErr.Code, skulookup.SKUErrInvalidService) + } +} + +// -------------------------------------------------------------------------- +// 8. Duplicate skuId within one service's catalog: Warning produced, first +// match used, no crash. +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_DuplicateSKUIDWarns(t *testing.T) { + first := lookupSKU( + "SKU-DUP", "First matching row", + []string{"us-central1"}, "", nil, + []tierSpec{{start: 0, units: "0", nanos: 10_000_000}}, + ) + second := lookupSKU( + "SKU-DUP", "Second matching row (should be ignored)", + []string{"us-central1"}, "", nil, + []tierSpec{{start: 0, units: "0", nanos: 99_000_000}}, + ) + server := newMultiServiceSKUServer(t, map[string]http.HandlerFunc{ + computeServiceID: jsonSKUsHandler([]map[string]any{first, second}), + }) + defer server.Close() + p := newTestProvider(t, server) + + res, err := p.LookupSKUAcrossRegionsGeneric( + context.Background(), "SKU-DUP", []string{"us-central1"}, "compute", skulookup.SKUHint{}) + if err != nil { + t.Fatalf("LookupSKUAcrossRegionsGeneric error: %v", err) + } + if len(res.Warnings) == 0 { + t.Fatalf("expected a duplicate-skuId warning, got none") + } + found := false + for _, w := range res.Warnings { + if strings.Contains(w, "SKU-DUP") { + found = true + } + } + if !found { + t.Errorf("expected a warning mentioning the duplicated skuId, got: %v", res.Warnings) + } + rr := res.Regions[0] + if len(rr.Prices) != 1 { + t.Fatalf("expected 1 price (first match used), got %d", len(rr.Prices)) + } + if abs(rr.Prices[0].PricePerUnit-0.01) > 1e-9 { + t.Errorf("expected the FIRST matching row's price (0.01) to be used, got %v", rr.Prices[0].PricePerUnit) + } + if rr.Prices[0].Description != "First matching row" { + t.Errorf("expected the first matching row's description, got %q", rr.Prices[0].Description) + } +} diff --git a/opencloudcosts-go/internal/providers/gcp/testhooks.go b/opencloudcosts-go/internal/providers/gcp/testhooks.go new file mode 100644 index 0000000..0c803a3 --- /dev/null +++ b/opencloudcosts-go/internal/providers/gcp/testhooks.go @@ -0,0 +1,31 @@ +package gcp + +import ( + "net/http" + + "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/cache" + "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/config" +) + +// testhooks.go exports a narrow, explicitly-named test-only seam for +// constructing a *Provider wired to a fake HTTP server, mirroring +// internal/providers/aws/testhooks.go's rationale: a regular (non-_test.go) +// file is required (not export_test.go) because a *different* package's +// tests need this — internal/tools's tools_test package, which drives +// raw-SKU tools (get_price_by_sku, estimate_bom, compare_bom_regions) against +// a real *gcpprovider.Provider without a live network call. Everything here +// is a thin, obviously-test-only wrapper; NewProvider's production behavior +// is unchanged. + +// NewProviderForTesting constructs a *Provider identical to NewProvider +// except its Cloud Billing Catalog base URL and HTTP client are overridden +// to point at a caller-supplied fake server (typically an httptest.Server). +func NewProviderForTesting(cfg *config.Config, cm *cache.CacheManager, baseURL string, httpClient *http.Client) *Provider { + return &Provider{ + cfg: cfg, + cache: cm, + auth: newGCPAuthProvider(cfg), + httpClient: httpClient, + baseURL: baseURL, + } +} diff --git a/opencloudcosts-go/internal/server/server.go b/opencloudcosts-go/internal/server/server.go index d829dad..e555b66 100644 --- a/opencloudcosts-go/internal/server/server.go +++ b/opencloudcosts-go/internal/server/server.go @@ -471,7 +471,7 @@ const ( schemaCompareBOMRegions = `{ "properties": { "items": { - "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, AWS-only) — see tool description.", + "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, provider aws or gcp) — see tool description.", "items": { "additionalProperties": true, "type": "object" @@ -583,7 +583,7 @@ const ( schemaEstimateBOM = `{ "properties": { "items": { - "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, AWS-only) — see tool description.", + "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, provider aws or gcp) — see tool description.", "items": { "additionalProperties": true, "type": "object" @@ -2997,9 +2997,9 @@ const ( descComparePrices = "\n Compare pricing for any service across multiple regions.\n\n Fetches concurrently. Returns results sorted cheapest first, with % delta between\n cheapest and most expensive. Optionally shows delta vs a baseline region.\n\n Args:\n spec: PricingSpec dict (same as get_price). The region field is overridden\n per comparison — you can pass any region in the spec.\n regions: List of region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\", \"ap-northeast-1\"]\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n " - descGetPriceBySKU = "\n Resolve a raw AWS usage-type/SKU string — exactly as it appears in a Cost & Usage Report\n (CUR) export, e.g. \"CAN1-BoxUsage:r5a.8xlarge\" — to a price, across one or more regions.\n\n Use this instead of get_price/compare_prices when you have a raw billing export line item\n (a \"UsageType\" or \"SKU\" column value) and need to reconcile it against current public\n pricing, rather than starting from a known resource_type/domain spec. This tool strips the\n region-prefix token from the usage-type string (e.g. \"CAN1-\", \"EU-\", or no prefix at all\n for us-east-1) to get a region-independent suffix, then matches that suffix against each\n target region's pricing catalog.\n\n If service is omitted, the AWS servicecode is inferred from the usage-type pattern (e.g.\n \"BoxUsage:\" implies AmazonEC2, \"LCUUsage\" implies AWSELB) — service_source in the response\n indicates \"explicit\" or \"inferred\". If a supplied service hint finds no match but the\n inferred servicecode does (real CUR data isn't always internally consistent — e.g. data-\n transfer usage types sometimes appear against an \"AmazonEC2\" AWS Product column but are\n actually billed under AWSDataTransfer), the tool falls back to the inferred match and flags\n service_mismatch on that region's result rather than reporting no match.\n\n Some usage-type suffixes are shared by multiple distinct billable products (e.g. ELB's\n \"LCUUsage\" suffix matches Application/Network/Gateway load balancer pricing alike; RDS's\n \"InstanceUsage:\" suffix matches every database engine on that instance type). When\n that happens the affected region is reported under ambiguous_in (NOT in\n all_regions_sorted/cheapest_price/most_expensive_price — an ambiguous multi-product match\n is never silently resolved to \"cheapest\"), with every candidate row listed under\n alternate_matches. Pass operation and/or product_family — the same columns a CUR export\n carries alongside the usage-type/SKU column — to resolve it: e.g. for an Application Load\n Balancer LCU usage-type, product_family=\"Load Balancer-Application\" picks the correct row\n out of the Application/Network/Gateway alternatives.\n\n Regions with no catalog entry for the resolved suffix are reported in no_mapping_in\n (checked, not found) — this is distinct from errors_in (the catalog fetch itself failed)\n and from ambiguous_in (matched, but more than one product row and not yet resolved).\n Known limitation: compound inter-region/wavelength data-transfer SKUs with two region-\n shaped tokens (e.g. \"USE1WL1ATL1-CAN1-AWS-Out-Bytes\") are not fully resolved by the\n single-prefix-strip model; these produce a warning rather than a silently wrong match.\n\n Args:\n provider: Cloud provider — only \"aws\" is supported (raw usage-type SKUs are an AWS\n CUR concept with no GCP/Azure equivalent).\n sku: The raw usage-type/SKU string exactly as it appears in the CUR export.\n service: Optional AWS servicecode hint (e.g. \"AmazonEC2\", \"AWSELB\", \"AmazonRDS\",\n \"AmazonDynamoDB\", \"AmazonElastiCache\", \"AWSDataTransfer\"). If omitted, it is\n inferred from the usage-type pattern.\n regions: List of AWS region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"]. Required,\n max 30.\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n operation: Optional disambiguating hint — the AWS product \"operation\" attribute (e.g.\n \"CreateDBInstance:0021\" identifies Aurora PostgreSQL among RDS engines on\n the same instance type), matched case-insensitively. Use this when a region\n comes back in ambiguous_in.\n product_family: Optional disambiguating hint — the AWS top-level \"productFamily\" (e.g.\n \"Load Balancer-Application\" for an ALB vs NLB/GLB), matched\n case-insensitively. Use this when a region comes back in ambiguous_in.\n\n Examples:\n {\"sku\": \"CAN1-BoxUsage:r5a.8xlarge\", \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n {\"sku\": \"CAN1-AWS-Out-Bytes\", \"service\": \"AmazonEC2\", \"regions\": [\"ca-central-1\"]}\n {\"sku\": \"CAN1-LCUUsage\", \"service\": \"AWSELB\", \"product_family\": \"Load Balancer-Application\",\n \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n " + descGetPriceBySKU = "\n Resolve a raw AWS usage-type/SKU string — exactly as it appears in a Cost & Usage Report\n (CUR) export, e.g. \"CAN1-BoxUsage:r5a.8xlarge\" — or a raw GCP Cloud Billing Catalog skuId\n string (provider=\"gcp\") to a price, across one or more regions.\n\n Use this instead of get_price/compare_prices when you have a raw billing export line item\n (a \"UsageType\"/\"SKU\" column value, or a GCP skuId) and need to reconcile it against current\n public pricing, rather than starting from a known resource_type/domain spec. This tool strips the\n region-prefix token from the usage-type string (e.g. \"CAN1-\", \"EU-\", or no prefix at all\n for us-east-1) to get a region-independent suffix, then matches that suffix against each\n target region's pricing catalog. (This prefix-stripping step is AWS-only; see the GCP\n paragraph below for how provider=\"gcp\" resolves instead.)\n\n If service is omitted, the AWS servicecode is inferred from the usage-type pattern (e.g.\n \"BoxUsage:\" implies AmazonEC2, \"LCUUsage\" implies AWSELB) — service_source in the response\n indicates \"explicit\" or \"inferred\". If a supplied service hint finds no match but the\n inferred servicecode does (real CUR data isn't always internally consistent — e.g. data-\n transfer usage types sometimes appear against an \"AmazonEC2\" AWS Product column but are\n actually billed under AWSDataTransfer), the tool falls back to the inferred match and flags\n service_mismatch on that region's result rather than reporting no match.\n\n Some usage-type suffixes are shared by multiple distinct billable products (e.g. ELB's\n \"LCUUsage\" suffix matches Application/Network/Gateway load balancer pricing alike; RDS's\n \"InstanceUsage:\" suffix matches every database engine on that instance type). When\n that happens the affected region is reported under ambiguous_in (NOT in\n all_regions_sorted/cheapest_price/most_expensive_price — an ambiguous multi-product match\n is never silently resolved to \"cheapest\"), with every candidate row listed under\n alternate_matches. Pass operation and/or product_family — the same columns a CUR export\n carries alongside the usage-type/SKU column — to resolve it: e.g. for an Application Load\n Balancer LCU usage-type, product_family=\"Load Balancer-Application\" picks the correct row\n out of the Application/Network/Gateway alternatives.\n\n Regions with no catalog entry for the resolved suffix are reported in no_mapping_in\n (checked, not found) — this is distinct from errors_in (the catalog fetch itself failed)\n and from ambiguous_in (matched, but more than one product row and not yet resolved).\n Known limitation: compound inter-region/wavelength data-transfer SKUs with two region-\n shaped tokens (e.g. \"USE1WL1ATL1-CAN1-AWS-Out-Bytes\") are not fully resolved by the\n single-prefix-strip model; these produce a warning rather than a silently wrong match.\n\n For provider=\"gcp\": sku is a Cloud Billing Catalog skuId (e.g. \"D041-9EFB-5FA5\"), matched\n exactly (no prefix-stripping) against the service hint's catalog if given, or every\n onboarded service's catalog if service is omitted. operation/product_family hints are AWS-only and\n ignored for GCP — a matched skuId is unambiguous, so ambiguous_in does not apply; instead\n some GCP SKUs are usage-volume tiered (result entries carry \"tiered\": true plus an\n \"all_tier_rates\" array; the entry's own price_per_unit is the lowest tier's rate). GCP's\n service_source is \"explicit\" (service given) or \"scanned_all\" (no hint — every onboarded\n service's catalog is searched) rather than AWS's \"inferred\".\n\n Args:\n provider: Cloud provider — \"aws\" (raw usage-type SKUs) or \"gcp\" (raw Cloud Billing\n Catalog skuId strings). Defaults to \"aws\".\n sku: The raw usage-type/SKU string exactly as it appears in the CUR export (AWS), or\n the raw Cloud Billing Catalog skuId string (GCP).\n service: Optional service hint. For AWS, a servicecode (e.g. \"AmazonEC2\", \"AWSELB\",\n \"AmazonRDS\", \"AmazonDynamoDB\", \"AmazonElastiCache\", \"AWSDataTransfer\") — if\n omitted, it is inferred from the usage-type pattern. For GCP, one of the\n onboarded service names (e.g. \"compute\", \"gcs\", \"cloudsql\", \"gke\",\n \"memorystore\", \"kms\", \"dns\", \"firestore\", \"pubsub\", \"vertex\", \"bigquery\",\n \"monitoring\", \"armor\") — if omitted, every onboarded service is searched.\n regions: List of region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"] (AWS) or\n [\"us-central1\"] (GCP). Required, max 30.\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n operation: Optional AWS-only disambiguating hint — the AWS product \"operation\"\n attribute (e.g. \"CreateDBInstance:0021\" identifies Aurora PostgreSQL among\n RDS engines on the same instance type), matched case-insensitively. Use this\n when a region comes back in ambiguous_in. Ignored for provider=\"gcp\".\n product_family: Optional AWS-only disambiguating hint — the AWS top-level\n \"productFamily\" (e.g. \"Load Balancer-Application\" for an ALB vs\n NLB/GLB), matched case-insensitively. Use this when a region comes back\n in ambiguous_in. Ignored for provider=\"gcp\".\n\n Examples:\n {\"sku\": \"CAN1-BoxUsage:r5a.8xlarge\", \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n {\"sku\": \"CAN1-AWS-Out-Bytes\", \"service\": \"AmazonEC2\", \"regions\": [\"ca-central-1\"]}\n {\"sku\": \"CAN1-LCUUsage\", \"service\": \"AWSELB\", \"product_family\": \"Load Balancer-Application\",\n \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n {\"provider\": \"gcp\", \"sku\": \"D041-9EFB-5FA5\", \"regions\": [\"us-central1\", \"europe-west1\"]}\n " - descGetPricesBySKU = "\n Batch form of get_price_by_sku: resolve many raw AWS usage-type/SKU strings — each exactly\n as it appears in a Cost & Usage Report (CUR) export — against the same set of target\n regions in one call.\n\n Use this to reconcile many CUR line items at once (e.g. every distinct usage-type/SKU in a\n monthly export) instead of issuing one get_price_by_sku call per SKU. Each sku is resolved\n independently via the same logic get_price_by_sku uses, so per-region ambiguous_in/\n no_mapping_in/errors_in bucketing and baseline_region deltas all apply per sku exactly as\n they would in a standalone get_price_by_sku call — this tool only adds the batching and\n aggregation layer on top.\n\n service/operation/product_family hints are not supported here (they are inherently\n per-sku, and different SKUs in a batch usually resolve to different services) — the AWS\n servicecode is inferred per sku from its usage-type pattern. If a particular sku needs a\n hint to resolve an ambiguous_in entry, follow up with a single get_price_by_sku call for\n that sku, passing operation/product_family.\n\n Each successfully-processed sku appears in \"results\", in the same order as the input skus\n list (NOT re-sorted by price — distinct SKUs commonly price in different units, e.g.\n per-hour vs per-GB vs per-request, that are not meaningfully comparable). A sku that fails\n outright (e.g. an empty string, or a usage-type pattern no service could be inferred for)\n is instead reported in the top-level \"errors\" map, keyed by that sku string, with\n message/status/retryable fields mirroring get_prices_batch's per-item error shape.\n\n Args:\n provider: Cloud provider — only \"aws\" is supported (raw usage-type SKUs are an AWS\n CUR concept with no GCP/Azure equivalent).\n skus: List of raw usage-type/SKU strings, each exactly as it appears in the CUR\n export. Required, max 25.\n regions: List of AWS region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"]. Required,\n max 30 (applies to every sku).\n baseline_region: Optional region for delta comparison, applied to every sku,\n e.g. \"us-east-1\".\n\n Examples:\n {\"skus\": [\"CAN1-BoxUsage:r5a.8xlarge\", \"USW2-BoxUsage:m5.large\"], \"regions\": [\"us-east-1\", \"ca-central-1\"]}\n " + descGetPricesBySKU = "\n Batch form of get_price_by_sku: resolve many raw AWS usage-type/SKU strings — each exactly\n as it appears in a Cost & Usage Report (CUR) export — or many raw GCP Cloud Billing Catalog\n skuId strings (provider=\"gcp\") — against the same set of target regions in one call.\n\n Use this to reconcile many CUR line items (or GCP skuIds) at once instead of issuing one\n get_price_by_sku call per SKU. Each sku is resolved independently via the same logic\n get_price_by_sku uses, so per-region ambiguous_in/no_mapping_in/errors_in bucketing (AWS),\n tiered/all_tier_rates (GCP), and baseline_region deltas all apply per sku exactly as they\n would in a standalone get_price_by_sku call — this tool only adds the batching and\n aggregation layer on top.\n\n service/operation/product_family hints are not supported here (they are inherently\n per-sku, and different SKUs in a batch usually resolve to different services) — for AWS the\n servicecode is inferred per sku from its usage-type pattern; for GCP every onboarded\n service's catalog is searched per sku. If a particular sku needs a hint to resolve an\n ambiguous_in entry (AWS) or to narrow the search (GCP), follow up with a single\n get_price_by_sku call for that sku, passing service and, for AWS, operation/product_family.\n\n Each successfully-processed sku appears in \"results\", in the same order as the input skus\n list (NOT re-sorted by price — distinct SKUs commonly price in different units, e.g.\n per-hour vs per-GB vs per-request, that are not meaningfully comparable). A sku that fails\n outright (e.g. an empty string, or a usage-type pattern no service could be inferred for)\n is instead reported in the top-level \"errors\" map, keyed by that sku string, with\n message/status/retryable fields mirroring get_prices_batch's per-item error shape.\n\n Args:\n provider: Cloud provider — \"aws\" (raw usage-type SKUs) or \"gcp\" (raw Cloud Billing\n Catalog skuId strings). Defaults to \"aws\".\n skus: List of raw usage-type/SKU strings (AWS) or skuId strings (GCP). Required, max 25.\n regions: List of region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"] (AWS) or\n [\"us-central1\"] (GCP). Required, max 30 (applies to every sku).\n baseline_region: Optional region for delta comparison, applied to every sku,\n e.g. \"us-east-1\".\n\n Examples:\n {\"skus\": [\"CAN1-BoxUsage:r5a.8xlarge\", \"USW2-BoxUsage:m5.large\"], \"regions\": [\"us-east-1\", \"ca-central-1\"]}\n {\"provider\": \"gcp\", \"skus\": [\"D041-9EFB-5FA5\"], \"regions\": [\"us-central1\", \"europe-west1\"]}\n " descSearchPricing = "Deprecated helper that redirects to the correct tools. Use describe_catalog to browse available services by domain/provider, or get_price with a known spec." @@ -3015,7 +3015,7 @@ const ( descDescribeCatalog = "\n Discover what each provider supports and how to call get_price.\n\n - No args → full support matrix across all configured providers.\n - provider only → all domains/services for that provider.\n - provider + domain [+ service] → targeted guidance with required_fields,\n supported_terms, filter_hints, and a ready-to-use example_invocation\n you can pass directly to get_price.\n\n Use this before get_price when unsure of exact field names or values.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\". Empty = all providers.\n domain: Domain — \"compute\", \"storage\", \"database\", \"ai\", \"container\",\n \"serverless\", \"analytics\", \"network\", \"observability\". Empty = all.\n service: Service — e.g. \"bedrock\", \"rds\", \"gke\", \"bigquery\". Empty = all.\n " - descCompareBOMRegions = "\n Compare a Bill of Materials' total monthly cost across multiple AWS regions.\n\n v1 scope: AWS-only. Each item is an open PricingSpec dict, same shape as\n estimate_bom's items (provider, domain, resource_type/region/etc, plus\n quantity/hours_per_month/size_gb/description) — or a raw-SKU dict\n (sku, region, plus optional service/operation/product_family) for a CUR\n usage-type/SKU string, AWS-only. The region field on each item is\n overridden per comparison — pass any region in the item dicts.\n Weighting and a providers filter are not supported yet. Non-AWS items\n are reported once under \"not_supported\" rather than guessed or dropped\n silently; GCP/Azure support is tracked separately.\n\n Returns regions[] sorted cheapest-first, each with total_monthly, the\n resolved line_items, and any per-item errors. Optionally shows delta vs\n a baseline region.\n\n Args:\n items: List of PricingSpec dicts (same shape as estimate_bom) — or\n raw-SKU dicts (sku, region, plus optional service/operation/\n product_family), AWS-only. See estimate_bom for full item format.\n regions: List of AWS region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\"].\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n " + descCompareBOMRegions = "\n Compare a Bill of Materials' total monthly cost across multiple regions.\n\n v1 scope: PricingSpec-dict items are AWS-only. Each item is an open PricingSpec dict, same\n shape as estimate_bom's items (provider, domain, resource_type/region/etc, plus\n quantity/hours_per_month/size_gb/description) — or a raw-SKU dict (sku, region, plus\n optional service/operation/product_family) for a CUR usage-type/SKU string (provider=\"aws\"\n or omitted) or a GCP Cloud Billing Catalog skuId string (provider=\"gcp\"; operation/\n product_family are ignored for GCP). The region field on each item is overridden per\n comparison — pass any region in the item dicts. A region's region_name is only populated\n from the region-code display maps when every resolvable item in the call shares one\n provider; a mixed-provider call (e.g. an AWS item and a GCP item together) falls back to\n the bare region code instead of guessing whose naming applies.\n Weighting and a providers filter are not supported yet. Unsupported items\n (a PricingSpec-dict item naming a non-AWS provider, or a raw-SKU item naming a provider\n other than aws/gcp) are reported once under \"not_supported\" rather than guessed or dropped\n silently; full GCP/Azure PricingSpec-dict support is tracked separately.\n\n Returns regions[] sorted cheapest-first, each with total_monthly, the\n resolved line_items, and any per-item errors. Optionally shows delta vs\n a baseline region.\n\n Args:\n items: List of PricingSpec dicts (same shape as estimate_bom, AWS-only) — or\n raw-SKU dicts (sku, region, plus optional service/operation/\n product_family), provider \"aws\" (default) or \"gcp\". See estimate_bom for full\n item format.\n regions: List of region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\"].\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n " descGetCoverage = "\n Report which domains/services this server actually covers, per provider.\n\n v1 scope: structural coverage from the catalog only — each domain is\n reported as \"catalog\" (with its known services) unless the provider\n has no entry for it at all. This does NOT fan out a live get_price call\n per region — whether a specific region's live price is a real catalog\n rate or a degraded fallback constant is only observable by calling\n get_price for that spec and checking its \"fallback\" field, since that\n is a live fetch outcome rather than a fixed property of the catalog.\n\n Use this to answer \"what does this server know about\" before trial-\n and-error against describe_catalog and individual get_price calls.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\". Empty = all\n configured providers.\n " @@ -3027,7 +3027,7 @@ const ( descWarmCache = "\n Pre-populate the pricing cache for a provider before a large sweep (e.g. a\n multi-region compare_prices or get_prices_batch call), so that sweep hits a warm\n cache instead of paying fetch latency on every combination.\n\n Resolves each requested service to its catalog example_invocation (the same data\n describe_catalog returns) and fans the resulting spec x region combinations out\n concurrently, mirroring the compare_prices/get_prices_batch fan-out pattern.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\"\n regions: List of region codes to warm, e.g. [\"us-east-1\", \"eu-west-1\"]\n services: Optional list of service names or describe_catalog keys, e.g.\n [\"ec2\", \"rds\", \"compute/fargate\"]. Omit to warm every service the\n provider's catalog has an example invocation for.\n " - descEstimateBOM = "\n Use this tool for total infrastructure cost, TCO, monthly spend for a multi-resource\n stack, or cost comparison between architectures.\n\n Handles compute + storage + database + AI together in a single call — do NOT call\n get_price individually for multi-resource questions; use this tool instead.\n\n Returns per-item and total monthly/annual costs with real public pricing data,\n plus a not_included list of supplementary costs (egress, load balancers, monitoring).\n These are SUPPLEMENTARY — only price them if the user asked for TCO; for most\n questions just note 'additional costs may apply'.\n\n Each item should be a PricingSpec dict PLUS a quantity field:\n - provider: \"aws\" | \"gcp\" | \"azure\"\n - domain: \"compute\" | \"storage\" | \"database\" | \"ai\" | ...\n - region: region code\n - quantity: number of units (default 1)\n - hours_per_month: hours/month for compute (default 730 = always-on)\n - description: optional label for this line item\n Plus domain-specific fields (see get_price or describe_catalog for details).\n\n An item may instead be a raw-SKU dict (AWS-only): {\"sku\": \"...\",\n \"region\": \"us-east-1\", \"quantity\": 3} — same CUR usage-type/SKU\n string get_price_by_sku resolves, optionally with service/operation/\n product_family hints to disambiguate.\n\n Examples:\n Compute + database + storage on AWS:\n [\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\", \"quantity\": 3},\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r6g.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"},\n {\"provider\": \"aws\", \"domain\": \"storage\", \"storage_type\": \"gp3\", \"size_gb\": 500, \"region\": \"us-east-1\"}\n ]\n\n Mixed cloud:\n [\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"quantity\": 2},\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\", \"quantity\": 1}\n ]\n " + descEstimateBOM = "\n Use this tool for total infrastructure cost, TCO, monthly spend for a multi-resource\n stack, or cost comparison between architectures.\n\n Handles compute + storage + database + AI together in a single call — do NOT call\n get_price individually for multi-resource questions; use this tool instead.\n\n Returns per-item and total monthly/annual costs with real public pricing data,\n plus a not_included list of supplementary costs (egress, load balancers, monitoring).\n These are SUPPLEMENTARY — only price them if the user asked for TCO; for most\n questions just note 'additional costs may apply'.\n\n Each item should be a PricingSpec dict PLUS a quantity field:\n - provider: \"aws\" | \"gcp\" | \"azure\"\n - domain: \"compute\" | \"storage\" | \"database\" | \"ai\" | ...\n - region: region code\n - quantity: number of units (default 1)\n - hours_per_month: hours/month for compute (default 730 = always-on)\n - description: optional label for this line item\n Plus domain-specific fields (see get_price or describe_catalog for details).\n\n An item may instead be a raw-SKU dict: {\"sku\": \"...\",\n \"region\": \"us-east-1\", \"quantity\": 3} — the same raw CUR usage-type/SKU string (provider\n \"aws\", default) or GCP Cloud Billing Catalog skuId string (provider \"gcp\") get_price_by_sku\n resolves, optionally with service/operation/product_family hints to disambiguate\n (operation/product_family are AWS-only; ignored for provider \"gcp\"). A GCP SKU with\n usage-volume tiers is costed at the tier matching this item's quantity.\n\n Examples:\n Compute + database + storage on AWS:\n [\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\", \"quantity\": 3},\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r6g.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"},\n {\"provider\": \"aws\", \"domain\": \"storage\", \"storage_type\": \"gp3\", \"size_gb\": 500, \"region\": \"us-east-1\"}\n ]\n\n Mixed cloud:\n [\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"quantity\": 2},\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\", \"quantity\": 1}\n ]\n " descEstimateUnitEconomics = "\n Estimate per-unit economics (cost per user, per request, per transaction) given\n a Bill of Materials and expected monthly usage volume.\n\n Args:\n items: Same format as estimate_bom — list of cloud resource PricingSpec dicts\n plus quantity field. See estimate_bom for full item format.\n units_per_month: Monthly volume being measured (e.g. 10000 users)\n unit_label: What the unit represents — \"user\", \"request\", \"transaction\", etc.\n " diff --git a/opencloudcosts-go/internal/skulookup/skulookup.go b/opencloudcosts-go/internal/skulookup/skulookup.go new file mode 100644 index 0000000..34b78ca --- /dev/null +++ b/opencloudcosts-go/internal/skulookup/skulookup.go @@ -0,0 +1,203 @@ +// Package skulookup holds the provider-agnostic raw-SKU-lookup types shared +// by every provider that implements a get_price_by_sku-style lookup (AWS's +// CUR usage-type/SKU strings, GCP's Cloud Billing Catalog skuId strings, ...). +// +// These types were originally declared locally in +// internal/providers/aws/aws_sku_lookup.go (the first, and for a while only, +// implementation). This package hoists them out so a second provider (GCP, +// see internal/providers/gcp/gcp_sku_lookup.go) can implement the same +// SKULookupProvider interface without importing the aws package, and so the +// tool-handler layer (internal/tools) can resolve either provider through one +// generic code path instead of hardcoding *awsprovider.Provider everywhere. +// +// internal/providers/aws/aws_sku_lookup.go re-declares every one of these +// names as a type alias / const alias pointing back here, so every existing +// call site that spells them as awsprovider.SKULookupError, +// awsprovider.SKUErrSKURequired, etc. continues to compile unchanged — a type +// alias makes the old and new names identical types, not merely convertible +// ones. +// +// This package imports only internal/models, so it carries no risk of an +// import cycle with internal/providers/aws or internal/providers/gcp. +package skulookup + +import ( + "context" + + "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/models" +) + +// SKUHint bundles the optional AWS-specific disambiguating hints +// (operation/productFamily) that narrow a usage-type suffix matching more +// than one distinct billable product down to a single row. GCP does not use +// either field today (see gcp_sku_lookup.go's doc comment for why) but +// accepts SKUHint for interface conformance so both providers share one +// method signature. +type SKUHint struct { + OperationHint string + ProductFamilyHint string +} + +// SKULookupProvider is implemented by every provider that supports raw-SKU +// lookup (today: AWS via *awsprovider.Provider.LookupSKUAcrossRegionsGeneric, +// GCP via *gcpprovider.Provider.LookupSKUAcrossRegionsGeneric). The +// tool-handler layer (internal/tools) resolves a concrete provider to this +// interface once, instead of hardcoding *awsprovider.Provider at every raw-SKU +// call site. +type SKULookupProvider interface { + LookupSKUAcrossRegionsGeneric(ctx context.Context, sku string, regions []string, serviceHint string, hint SKUHint) (*SKULookupResult, error) +} + +// -------------------------------------------------------------------------- +// Structured errors +// -------------------------------------------------------------------------- + +// Error codes returned via SKULookupError.Code, for the tool-handler layer to +// switch on when building a structured JSON error response. Exact string +// values are unchanged from their original home in aws_sku_lookup.go — every +// existing caller (including any that may compare against the literal +// string, not just the const) keeps working. +const ( + SKUErrUnsupportedProvider = "unsupported_provider" + SKUErrSKURequired = "sku_required" + SKUErrSKUTooLong = "sku_too_long" + SKUErrRegionsRequired = "regions_required" + SKUErrTooManyRegions = "too_many_regions" + SKUErrInvalidService = "invalid_service" + SKUErrServiceUndeterminable = "service_undeterminable" + SKUErrHintTooLong = "hint_too_long" +) + +// SKULookupError is returned for request-level failures that apply to the +// whole lookup (bad input, unsupported provider) as opposed to a single +// region's result, which is instead represented as a non-error entry inside +// SKULookupResult.Regions (see that type's docs for why). +type SKULookupError struct { + Code string + Message string +} + +func (e *SKULookupError) Error() string { return e.Message } + +// -------------------------------------------------------------------------- +// Hint-resolution status codes +// -------------------------------------------------------------------------- + +// Hint-resolution status codes, surfaced on SKULookupRegionResult.HintStatus +// so a caller can tell *why* a region is still ambiguous rather than just +// seeing "ambiguous: true" again. Exact string values are unchanged from +// their original home in aws_sku_lookup.go. +const ( + // HintStatusNoHint means no disambiguating hint was supplied — resolution + // fell back to the provider's own default narrowing (or, if that also + // failed to narrow, plain ambiguity). + HintStatusNoHint = "no_hint_supplied" + // HintStatusResolved means a supplied hint narrowed the candidates to + // exactly one row. + HintStatusResolved = "resolved_by_hint" + // HintStatusNoMatch means a hint was supplied but matched zero candidate + // rows — resolution fails closed: the original unfiltered candidate set + // is returned, still ambiguous, rather than silently ignoring the hint. + HintStatusNoMatch = "hint_no_match" + // HintStatusAmbiguous means a hint was supplied and matched more than one + // row (canonical-default-style narrowing was then tried on that + // hint-filtered subset and still could not get to exactly one). + HintStatusAmbiguous = "hint_ambiguous" +) + +// -------------------------------------------------------------------------- +// Public result types +// -------------------------------------------------------------------------- + +// SKULookupRegionResult is the per-region outcome of a SKU lookup. Exactly +// one of the following holds, mirroring how the rest of this codebase +// distinguishes "we looked and found nothing" from "we couldn't look": +// - len(Prices) > 0: a match was found; ServiceUsed names the service +// (AWS servicecode, or GCP service ID) whose catalog contained it. +// - NoMapping == true: every candidate service's catalog was fetched +// successfully for this region, but no product row matched the input +// SKU. This is the explicit "no mapping found" result the caller needs +// to distinguish "priced but not in this region" (or "not modeled by +// this provider at all") from a transient failure. +// - Error != "": the region shape check failed, or every candidate +// service's catalog fetch itself failed (e.g. network/HTTP error) — we +// don't actually know whether a match exists. +type SKULookupRegionResult struct { + Region string `json:"region"` + + // ServiceUsed names the service whose catalog produced the match (AWS + // servicecode, or GCP service ID). Only set when len(Prices) > 0. + ServiceUsed string `json:"service_used,omitempty"` + + // ServiceMismatch is true when ServiceUsed differs from the caller's + // explicit service hint — i.e. the hint's catalog had no match, but a + // fallback catalog did. + ServiceMismatch bool `json:"service_mismatch,omitempty"` + + // Prices holds the resolved candidate row(s) for this region. In the + // common case this is a single row. When multiple product rows are + // legitimate alternates requiring disambiguation, all remaining + // candidates are kept here and Ambiguous is set, rather than silently + // picking one and reporting it as *the* price. See Tiered below for the + // different (non-ambiguous) case of one matched item with more than one + // usage-volume tier. + Prices []models.NormalizedPrice `json:"prices,omitempty"` + + // Ambiguous is true when Prices contains more than one row that the + // provider could not narrow down to a single canonical match — the + // caller must disambiguate using Prices[i].Attributes / Description / + // SKUID rather than trusting a single "the" price. + Ambiguous bool `json:"ambiguous,omitempty"` + + // HintStatus explains *why* Ambiguous is what it is — see the + // HintStatus* constants above. Only meaningful when there was more than + // one candidate to disambiguate in the first place. + HintStatus string `json:"hint_status,omitempty"` + + // Tiered is true when Prices holds multiple genuine usage-volume tiers + // of ONE matched item (ascending by usage threshold), not alternate + // candidates requiring disambiguation. AWS never sets this (always + // false/omitted, since its usage-type suffix model does not surface + // tiered rate schedules through this path). GCP sets it when a matched + // SKU has more than one tiered rate (see gcp_sku_lookup.go). + Tiered bool `json:"tiered,omitempty"` + + NoMapping bool `json:"no_mapping,omitempty"` + + Error string `json:"error,omitempty"` + + // AttemptedServices lists the services searched for this region (AWS + // servicecodes, or GCP service IDs), in search order, for + // diagnostic/debugging purposes. + AttemptedServices []string `json:"attempted_services,omitempty"` +} + +// SKULookupResult is the full result of a get_price_by_sku lookup: the +// canonicalized form of the input SKU, service-resolution provenance, and +// one SKULookupRegionResult per requested region (in the same order as the +// input regions list — the tool-handler layer is responsible for any +// cheapest-first sorting, mirroring how compare_prices sorts after fan-out). +type SKULookupResult struct { + SKU string `json:"sku"` + + // UsageTypePrefix is the stripped region-prefix token ("CAN1", "EU", "") + // and UsageTypeSuffix is the region-independent remainder used for + // cross-region matching (see stripUsageTypePrefix in + // aws_sku_lookup.go). These are AWS-only concepts — a raw AWS + // usage-type/SKU string encodes a region prefix that other providers' + // SKU identifiers have no equivalent of — and are left empty ("") by + // every other provider. + UsageTypePrefix string `json:"usage_type_prefix"` + UsageTypeSuffix string `json:"usage_type_suffix"` + + ServiceHint string `json:"service_hint,omitempty"` + InferredService string `json:"inferred_service,omitempty"` + // ServiceSource is "explicit" when ServiceHint was supplied and used as + // the primary candidate, or "inferred" when no hint was given and + // InferredService was derived from the usage-type pattern. + ServiceSource string `json:"service_source"` + + Warnings []string `json:"warnings,omitempty"` + + Regions []SKULookupRegionResult `json:"regions"` +} diff --git a/opencloudcosts-go/internal/tools/bom.go b/opencloudcosts-go/internal/tools/bom.go index 5524583..60c5ec9 100644 --- a/opencloudcosts-go/internal/tools/bom.go +++ b/opencloudcosts-go/internal/tools/bom.go @@ -8,23 +8,27 @@ // in src/opencloudcosts/tools/bom.py and src/opencloudcosts/tools/lookup.py. // // processBOMItems additionally resolves raw-SKU line items (issue #31, -// RC3-004) via resolveBOMSKUItem, which type-asserts a concrete -// *awsprovider.Provider to reuse LookupSKUAcrossRegions — the same AWS-only -// core get_price_by_sku uses (internal/tools/sku_lookup.go). That import is -// isolated to the resolveBOMSKUItem call site for the same reason -// sku_lookup.go isolates it: the rest of this file stays provider-agnostic. +// RC3-004; GCP parity RC3-015) via resolveBOMSKUItem, which resolves the +// concrete provider through resolveSKULookupProviderFromMap and calls +// LookupSKUAcrossRegionsGeneric — the same provider-agnostic core +// get_price_by_sku uses (internal/tools/sku_lookup.go). That +// skulookup/provider-specific plumbing is isolated to the resolveBOMSKUItem +// call site for the same reason sku_lookup.go isolates it: the rest of this +// file stays provider-agnostic. package tools import ( "context" "errors" "fmt" + "math" + "strconv" "strings" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/models" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/providers" - awsprovider "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/providers/aws" + "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/skulookup" ) // -------------------------------------------------------------------------- @@ -72,6 +76,80 @@ func bomMonthlyCost(price models.NormalizedPrice, quantity float64, hoursPerMont } } +// gcpTieredUsageVolume returns the usage volume that a tiered GCP price's +// tier_start_usage thresholds are denominated in, matching bomMonthlyCost's +// own per-unit scaling exactly (PER_HOUR: hoursPerMonth*quantity, +// PER_GB_MONTH: sizeGB*quantity, else: quantity) — tier thresholds must be +// compared against this same scaled volume, not raw quantity, since that is +// what the thresholds mean on the underlying GCP catalog (e.g. Firestore +// storage tiers are denominated in GB/month, not in the BoM item's +// "quantity" of database instances). +func gcpTieredUsageVolume(unit models.PriceUnit, quantity, hoursPerMonth, sizeGB float64) float64 { + switch unit { //nolint:exhaustive // mirrors bomMonthlyCost's own switch + case models.PriceUnitPerHour: + return hoursPerMonth * quantity + case models.PriceUnitPerGBMonth: + return sizeGB * quantity + default: + return quantity + } +} + +// tierStartUsage parses a tier row's tier_start_usage attribute (set by every +// GCP tiered-price row — see gcp_sku_lookup.go's gcpBuildMatchedPrices). ok +// is false when the attribute is absent or unparseable, so callers can +// gracefully skip a malformed tier rather than mis-bracket the whole +// calculation around it. +func tierStartUsage(t models.NormalizedPrice) (start float64, ok bool) { + startStr, present := t.Attributes["tier_start_usage"] + if !present { + return 0, false + } + start, err := strconv.ParseFloat(startStr, 64) + return start, err == nil +} + +// gcpGraduatedTieredCost computes the total monthly cost for a tiered +// (graduated) GCP price across every bracket usageVolume actually spans, +// mirroring the established graduated-billing model this codebase already +// uses elsewhere (gcp_networking.go's computeTieredCost, gcp_dns.go's +// addTieredEstimate): each tier's rate applies only to the portion of +// usageVolume that falls within that tier's own [start, nextStart) bracket, +// not to the entire usageVolume at one flat rate. tiers must be sorted +// ascending by tier_start_usage (LookupSKUAcrossRegionsGeneric's rr.Prices +// already are). Returns the total cost and the marginal (highest-bracket- +// reached) tier row, used only for the line item's displayed +// "price_per_unit" — a graduated rate has no single applicable per-unit +// price, so the marginal rate (the rate on the last unit of usage) is +// reported, the same convention computeTieredCost's own BlendedRatePerQty +// stands in for at its call sites. +func gcpGraduatedTieredCost(tiers []models.NormalizedPrice, usageVolume float64) (monthly float64, marginal models.NormalizedPrice) { + marginal = tiers[0] + for i, t := range tiers { + start, ok := tierStartUsage(t) + if !ok { + continue + } + if start > usageVolume { + // Tiers are ascending, so this and every later tier's bracket + // starts beyond usageVolume — neither is reached. + break + } + marginal = t + upper := math.Inf(1) + if i+1 < len(tiers) { + if nextStart, ok2 := tierStartUsage(tiers[i+1]); ok2 { + upper = nextStart + } + } + bracketEnd := math.Min(usageVolume, upper) + if bracketEnd > start { + monthly += (bracketEnd - start) * t.PricePerUnit + } + } + return monthly, marginal +} + // -------------------------------------------------------------------------- // Description fallback — mirrors Python getattr chain in estimate_bom // -------------------------------------------------------------------------- @@ -454,7 +532,7 @@ func resolveBOMSKUItem( providerName = "aws" } - awsP, errOut := resolveAWSSKUProviderFromMap(provs, providerName, "raw-SKU BoM items") + lookupP, errOut := resolveSKULookupProviderFromMap(provs, providerName, "raw-SKU BoM items") if errOut != nil { msg, _ := errOut["message"].(string) return bomLineItem{}, fmt.Sprintf("%s: %s (sku %q)", label, msg, sku) @@ -473,9 +551,12 @@ func resolveBOMSKUItem( return bomLineItem{}, errMsg } - result, err := awsP.LookupSKUAcrossRegions(ctx, providerName, sku, serviceHint, []string{region}, operation, productFamily) + result, err := lookupP.LookupSKUAcrossRegionsGeneric(ctx, sku, []string{region}, serviceHint, skulookup.SKUHint{ + OperationHint: operation, + ProductFamilyHint: productFamily, + }) if err != nil { - var skuErr *awsprovider.SKULookupError + var skuErr *skulookup.SKULookupError if errors.As(err, &skuErr) { return bomLineItem{}, fmt.Sprintf("%s: [%s] %s (sku %q)", label, skuErr.Code, skuErr.Message, sku) } @@ -498,7 +579,35 @@ func resolveBOMSKUItem( } price := rr.Prices[0] - monthly := bomMonthlyCost(price, quantity, hoursPerMonth, sizeGB) + // Tiered (GCP only — see skulookup.SKULookupRegionResult.Tiered): rr.Prices + // holds every usage-volume tier's rate, ascending by usage threshold, each + // tagged with its own Attributes["tier_start_usage"]. GCP's tiered SKUs are + // graduated/bracketed billing (the same model as gcp_networking.go's + // computeTieredCost / gcp_dns.go's addTieredEstimate): each tier's rate + // applies only to the slice of usage that falls within that tier's own + // bracket, not to the whole quantity at one flat rate. Tier-threshold + // comparisons must also use the same usage volume bomMonthlyCost itself + // scales by (hoursPerMonth*quantity for PER_HOUR, sizeGB*quantity for + // PER_GB_MONTH), not raw quantity, or a PER_GB_MONTH/PER_HOUR item would + // select tiers based on a number the customer never actually sees billed. + var monthly float64 + if rr.Tiered { + usageVolume := gcpTieredUsageVolume(price.Unit, quantity, hoursPerMonth, sizeGB) + monthly, price = gcpGraduatedTieredCost(rr.Prices, usageVolume) + // Report the blended effective rate (monthly / usage volume), not the + // marginal tier's own rate, in unitPrice.PricePerUnit — this is the + // same convention gcp_networking.go's computeTieredCost uses + // (BlendedRatePerGB = totalCost/dataGB). The marginal rate alone would + // make the line item's displayed price_per_unit * quantity disagree + // with its own monthly_cost whenever usage spans more than one + // bracket (e.g. qty=200 across a $0.10/$0.05 two-tier split: marginal + // $0.05 * 200 = $10 != the correct $15 monthly cost). + if usageVolume > 0 { + price.PricePerUnit = monthly / usageVolume + } + } else { + monthly = bomMonthlyCost(price, quantity, hoursPerMonth, sizeGB) + } annual := monthly * 12 lineDesc := description diff --git a/opencloudcosts-go/internal/tools/bom_test.go b/opencloudcosts-go/internal/tools/bom_test.go index e9107d4..e8f8165 100644 --- a/opencloudcosts-go/internal/tools/bom_test.go +++ b/opencloudcosts-go/internal/tools/bom_test.go @@ -1569,3 +1569,136 @@ func TestEstimateBOM_RawSKUItemAdvisoriesIncluded(t *testing.T) { t.Errorf("expected a 'Data transfer (egress)' advisory row, got: %v", notIncluded) } } + +// -------------------------------------------------------------------------- +// GCP raw-SKU BoM items (RC3-015) +// -------------------------------------------------------------------------- + +// TestEstimateBOM_GCPRawSKUItem verifies a GCP raw-SKU BoM item resolves +// against a real *gcpprovider.Provider and contributes to the BoM total — +// the GCP counterpart to TestEstimateBOM_RawSKUItem above. +func TestEstimateBOM_GCPRawSKUItem(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(gcpSKUCatalogFixtureJSON( + "0055-9F63-3A4D", "N1 Predefined Instance Core running in Americas", "us-central1", "0", 40_000_000))) + })) + defer server.Close() + realGCP := newGCPSKUTestProvider(t, server) + h := tools.New(map[string]tools.Provider{"gcp": realGCP}) + + items := []map[string]any{ + { + "sku": "0055-9F63-3A4D", + "provider": "gcp", + "service": "compute", + "region": "us-central1", + "quantity": float64(1), + }, + } + resp := callEstimateBOM(t, h, items) + + if _, ok := resp["error"]; ok { + t.Fatalf("expected success, got error: %v", resp["error"]) + } + lineItems, ok := resp["line_items"].([]any) + if !ok || len(lineItems) != 1 { + t.Fatalf("expected 1 line item, got %v", resp["line_items"]) + } + li := lineItems[0].(map[string]any) + if li["sku"] != "0055-9F63-3A4D" { + t.Errorf("expected sku field populated, got %v", li["sku"]) + } + if li["provider"] != "gcp" { + t.Errorf("expected provider gcp, got %v", li["provider"]) + } + + totals, ok := resp["totals"].(map[string]any) + if !ok { + t.Fatalf("expected totals in response, got %v", resp["totals"]) + } + monthly := totals["monthly"].(map[string]any) + // 0.04/hr * 730 hrs/mo (default) * quantity 1 = $29.20/mo. + if monthly["display"] != "$29.20/mo" { + t.Errorf("expected total monthly $29.20/mo, got %v", monthly["display"]) + } +} + +// TestEstimateBOM_GCPRawSKUItem_TieredQuantitySelectsCorrectTier verifies +// resolveBOMSKUItem's graduated tiered-billing rule (bom.go): each tier's +// rate applies only to the slice of usage that falls within that tier's own +// bracket, not to the whole quantity at one flat rate. Two BoM items share +// the same tiered GCP SKU but differ only in quantity, to exercise both a +// quantity that stays within the first bracket and one that spans both. +func TestEstimateBOM_GCPRawSKUItem_TieredQuantitySelectsCorrectTier(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(gcpSKUCatalogFixtureJSONTiered( + "SKU-TIER-BOM", "Tiered storage rate", "us-central1", "count", + []gcpTierFixture{ + {start: 0, units: "0", nanos: 100_000_000}, // $0.10/unit below 100 units + {start: 100, units: "0", nanos: 50_000_000}, // $0.05/unit at/above 100 units + }))) + })) + defer server.Close() + realGCP := newGCPSKUTestProvider(t, server) + h := tools.New(map[string]tools.Provider{"gcp": realGCP}) + + items := []map[string]any{ + { + "sku": "SKU-TIER-BOM", + "provider": "gcp", + "service": "gcs", + "region": "us-central1", + "quantity": float64(50), // below the second tier's start (100) → first/cheapest tier + "description": "low-quantity item", + }, + { + "sku": "SKU-TIER-BOM", + "provider": "gcp", + "service": "gcs", + "region": "us-central1", + "quantity": float64(200), // above the second tier's start (100) → that later tier + "description": "high-quantity item", + }, + } + resp := callEstimateBOM(t, h, items) + + if _, ok := resp["error"]; ok { + t.Fatalf("expected success, got error: %v", resp["error"]) + } + lineItems, ok := resp["line_items"].([]any) + if !ok || len(lineItems) != 2 { + t.Fatalf("expected 2 line items, got %v", resp["line_items"]) + } + + byDesc := map[string]map[string]any{} + for _, raw := range lineItems { + li := raw.(map[string]any) + byDesc[li["description"].(string)] = li + } + + low := byDesc["low-quantity item"] + if low == nil { + t.Fatalf("expected a low-quantity item line, got: %v", lineItems) + } + lowMonthly := low["monthly_cost"].(map[string]any) + // quantity 50 is below tier 2's start (100) → first/cheapest tier ($0.10/unit): 50 * 0.10 = $5.00/mo. + if lowMonthly["display"] != "$5.00/mo" { + t.Errorf("expected low-quantity item to use the first tier ($5.00/mo), got %v", lowMonthly["display"]) + } + + high := byDesc["high-quantity item"] + if high == nil { + t.Fatalf("expected a high-quantity item line, got: %v", lineItems) + } + highMonthly := high["monthly_cost"].(map[string]any) + // quantity 200 spans both brackets under graduated billing: the first + // 100 units at tier 1's $0.10/unit, plus the remaining 100 units at tier + // 2's $0.05/unit: 100*0.10 + 100*0.05 = $15.00/mo. + if highMonthly["display"] != "$15.00/mo" { + t.Errorf("expected high-quantity item to be billed graduated across both tiers ($15.00/mo), got %v", highMonthly["display"]) + } +} diff --git a/opencloudcosts-go/internal/tools/compare_bom.go b/opencloudcosts-go/internal/tools/compare_bom.go index 24607a9..c3f27f7 100644 --- a/opencloudcosts-go/internal/tools/compare_bom.go +++ b/opencloudcosts-go/internal/tools/compare_bom.go @@ -776,7 +776,7 @@ func (h *Handler) HandleCompareBOM( // Savings vs on-demand for committed terms. if canonTerm != "on_demand" && odMonthly > 0 && termRes.totalMonthly > 0 { savings := roundToTwoDecimal(odMonthly - termRes.totalMonthly) - pct := roundToTwoDecimal((odMonthly-termRes.totalMonthly)/odMonthly*100) + pct := roundToTwoDecimal((odMonthly - termRes.totalMonthly) / odMonthly * 100) termOut["savings_vs_on_demand"] = map[string]any{ "amount": savings, "percent": pct, @@ -850,9 +850,9 @@ func buildCompareSummary( } type providerCost struct { - name string - monthly float64 - savings float64 + name string + monthly float64 + savings float64 savingPct float64 } @@ -885,9 +885,9 @@ func buildCompareSummary( } costs = append(costs, providerCost{ - name: prov, - monthly: monthly, - savings: savings, + name: prov, + monthly: monthly, + savings: savings, savingPct: savingPct, }) } diff --git a/opencloudcosts-go/internal/tools/compare_bom_regions.go b/opencloudcosts-go/internal/tools/compare_bom_regions.go index b623d71..95e82fc 100644 --- a/opencloudcosts-go/internal/tools/compare_bom_regions.go +++ b/opencloudcosts-go/internal/tools/compare_bom_regions.go @@ -1,15 +1,25 @@ // compare_bom_regions.go implements the compare_bom_regions MCP tool. // -// v1 scope (issue #31, RC3-004): AWS-only, synchronous per-line region -// fan-out over PricingSpec-dict and raw-SKU items — no weighting or a -// providers filter yet. It is composed entirely from existing cross-provider -// machinery — +// v1 scope (issue #31, RC3-004): AWS-only for PricingSpec-dict items, +// synchronous per-line region fan-out — no weighting or a providers filter +// yet. It is composed entirely from existing cross-provider machinery — // estimate_bom's processBOMItems (bom.go) for per-item price resolution, and // compare_prices' region-fan-out + baseline-delta pattern (this file) for the // region loop — rather than new AWS-specific plumbing, so the input/output -// contract does not lock in an AWS-specific shape. Non-AWS items are reported -// once at the top level, tagged "not_supported", rather than guessed or -// dropped silently. +// contract does not lock in an AWS-specific shape. Non-AWS PricingSpec-dict +// items are reported once at the top level, tagged "not_supported", rather +// than guessed or dropped silently. +// +// Raw-SKU items (RC3-015, GCP parity) additionally accept provider=="gcp" — +// resolveBOMSKUItem (bom.go) already resolves either provider generically via +// resolveSKULookupProviderFromMap, so no per-region plumbing here needs to +// change, only the partitioning check below. Because a single +// compare_bom_regions call's resolvable items can therefore now span more +// than one provider (e.g. an AWS EC2 SKU and a GCP Compute Engine SKU in the +// same BoM), and a region's regionResult aggregates cost across every +// resolvable item for that region, there is no longer one single "the" +// provider to pass to regionDisplayNameFn — see the resolvableProviders +// computation and its use below. package tools import ( @@ -52,22 +62,25 @@ func (h *Handler) HandleCompareBOMRegions( }), nil, nil } - // Partition items up front: v1 only resolves AWS items. Non-AWS items are - // reported once (provider does not vary per region), not re-derived on - // every region iteration. + // Partition items up front: v1 resolves AWS PricingSpec-dict items and + // AWS/GCP raw-SKU items. Unsupported items are reported once (provider + // does not vary per region), not re-derived on every region iteration. var resolvable []map[string]any var notSupported []map[string]any for idx, item := range in.Items { label := fmt.Sprintf("Item %d", idx+1) // Raw-SKU items are implicitly AWS (same default get_price_by_sku - // applies to a missing provider) — but an item that explicitly names - // a non-AWS provider is routed to notSupported here, exactly like any - // other non-AWS item, rather than being rejected once per region - // inside processBOMItems below. + // applies to a missing provider) and, as of RC3-015, also accept an + // explicit provider=="gcp" — resolveBOMSKUItem resolves either + // provider generically. An item naming any other provider is routed + // to notSupported here, exactly like any other unsupported item, + // rather than being rejected once per region inside processBOMItems + // below. if _, ok := rawBOMSKU(item); ok { pvdrName, hasPvdr := item["provider"].(string) - if !hasPvdr || pvdrName == "" || strings.EqualFold(pvdrName, compareBOMRegionsV1Provider) { + if !hasPvdr || pvdrName == "" || strings.EqualFold(pvdrName, compareBOMRegionsV1Provider) || + strings.EqualFold(pvdrName, "gcp") { resolvable = append(resolvable, item) continue } @@ -75,7 +88,7 @@ func (h *Handler) HandleCompareBOMRegions( "item": label, "provider": pvdrName, "source": "not_supported", - "reason": "compare_bom_regions v1 is AWS-only (RC3-004) — this provider is not yet supported.", + "reason": "compare_bom_regions raw-SKU items support aws and gcp providers only — this provider is not yet supported.", }) continue } @@ -93,12 +106,41 @@ func (h *Handler) HandleCompareBOMRegions( resolvable = append(resolvable, item) } + // regionNameProvider decides what provider to pass to regionDisplayNameFn + // for the per-region "region_name" field below. A region's regionResult + // aggregates cost across every resolvable item for that region, so if + // resolvable items span more than one provider (an AWS item and a GCP + // item both requesting, say, region "us-central1"/"us-east-1"), there is + // no single correct provider whose display-name map applies — falling + // back to the bare region code (regionDisplayNameFn's own behavior for an + // unrecognized provider, see internal/utils/regions.go) is the smallest + // correct fix, rather than guessing one provider or resolving a display + // name per line item (region_name is a per-region, not per-line-item, + // field in this tool's response shape). + resolvableProviders := map[string]struct{}{} + for _, item := range resolvable { + pvdrName, _ := item["provider"].(string) + pvdrName = strings.ToLower(pvdrName) + if pvdrName == "" { + pvdrName = compareBOMRegionsV1Provider // raw-SKU/PricingSpec-dict default + } + resolvableProviders[pvdrName] = struct{}{} + } + regionNameProvider := compareBOMRegionsV1Provider + if len(resolvableProviders) == 1 { + for p := range resolvableProviders { + regionNameProvider = p + } + } else if len(resolvableProviders) > 1 { + regionNameProvider = "" // mixed providers: force the bare-region-code fallback + } + type regionResult struct { region string totalMonthly float64 lineItems []map[string]any errs []string - status string // ok | no_data + status string // ok | no_data | partial } sem := make(chan struct{}, 10) @@ -131,9 +173,17 @@ func (h *Handler) HandleCompareBOMRegions( total += li.monthlyCost liMaps = append(liMaps, li.toMap()) } + // A region with zero resolved line items is no_data. A region + // with some resolved and some errored is "partial" — its total + // is real but understated (some resolvable items failed), so it + // must not be reported as unqualified "ok" alongside regions + // where every item resolved cleanly. status := regionStatusOK - if len(lineItems) == 0 { + switch { + case len(lineItems) == 0: status = regionStatusNoData + case len(errs) > 0: + status = regionStatusPartial } results[idx] = regionResult{ region: rgn, @@ -171,15 +221,18 @@ func (h *Handler) HandleCompareBOMRegions( for _, r := range results { e := map[string]any{ "region": r.region, - "region_name": regionDisplayNameFn(compareBOMRegionsV1Provider, r.region), + "region_name": regionDisplayNameFn(regionNameProvider, r.region), "total_monthly": moneyDict(r.totalMonthly, "/mo"), "line_items": r.lineItems, } if len(r.errs) > 0 { e["errors"] = r.errs } - if r.status == regionStatusNoData { + switch r.status { + case regionStatusNoData: e["status"] = "no_data" + case regionStatusPartial: + e["status"] = "partial" } if in.BaselineRegion != "" { // Degrade gracefully (RC3-002): a missing baseline region nulls diff --git a/opencloudcosts-go/internal/tools/compare_bom_regions_test.go b/opencloudcosts-go/internal/tools/compare_bom_regions_test.go index 014f6cb..e4898cf 100644 --- a/opencloudcosts-go/internal/tools/compare_bom_regions_test.go +++ b/opencloudcosts-go/internal/tools/compare_bom_regions_test.go @@ -225,15 +225,26 @@ func TestCompareBOMRegions_RawSKUItem(t *testing.T) { } // TestCompareBOMRegions_RawSKUNonAWSProviderReportedOnce verifies a raw-SKU -// item with an explicit non-AWS provider is reported once in not_supported -// (Finding 1 fix), not duplicated once per compared region. +// item with an explicit unsupported (non-aws, non-gcp) provider is reported +// once in not_supported (Finding 1 fix), not duplicated once per compared +// region. +// +// NOTE: this test previously used provider="gcp" as its "unsupported" +// example. As of RC3-015 (GCP raw-SKU parity), "gcp" is legitimately +// accepted at the partition step above (HandleCompareBOMRegions), so it no +// longer exercises the not_supported path — see +// TestCompareBOMRegions_GCPRawSKUItem below for gcp's new (resolvable) +// behavior. This test now uses "azure" (still genuinely unsupported) so it +// continues to guard the not_supported path — and doubles as the regression +// check that widening acceptance to aws/gcp didn't accidentally start +// accepting azure too. func TestCompareBOMRegions_RawSKUNonAWSProviderReportedOnce(t *testing.T) { pvdr := newRegionPricedProvider(map[string]float64{"us-east-1": 0.192, "us-west-2": 0.150}) h := tools.New(map[string]tools.Provider{"aws": pvdr}) resp := callCompareBOMRegions(t, h, tools.CompareBOMRegionsInput{ Items: []map[string]any{ - {"sku": "BoxUsage:m5.xlarge", "provider": "gcp", "service": "AmazonEC2"}, + {"sku": "BoxUsage:m5.xlarge", "provider": "azure", "service": "AmazonEC2"}, }, Regions: []string{"us-east-1", "us-west-2"}, }) @@ -243,15 +254,66 @@ func TestCompareBOMRegions_RawSKUNonAWSProviderReportedOnce(t *testing.T) { t.Fatalf("expected exactly 1 not_supported entry, got: %v", resp["not_supported"]) } entry := notSupported[0].(map[string]any) - if entry["provider"] != "gcp" { - t.Errorf("expected gcp in not_supported entry, got %v", entry) + if entry["provider"] != "azure" { + t.Errorf("expected azure in not_supported entry, got %v", entry) } regions := resp["regions"].([]any) for _, r := range regions { region := r.(map[string]any) if errs, ok := region["errors"].([]any); ok && len(errs) > 0 { - t.Errorf("expected no per-region errors for the gcp raw-SKU item (should be reported once at top level), got: %v in region %v", errs, region["region"]) + t.Errorf("expected no per-region errors for the azure raw-SKU item (should be reported once at top level), got: %v in region %v", errs, region["region"]) } } } + +// TestCompareBOMRegions_GCPRawSKUItem verifies a GCP raw-SKU BoM item +// resolves per region against a real *gcpprovider.Provider — the GCP +// counterpart to TestCompareBOMRegions_RawSKUItem above, added for RC3-015. +func TestCompareBOMRegions_GCPRawSKUItem(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(gcpSKUCatalogFixtureJSON( + "0055-9F63-3A4D", "N1 Predefined Instance Core running in Americas", "us-central1", "0", 40_000_000))) + })) + defer server.Close() + realGCP := newGCPSKUTestProvider(t, server) + h := tools.New(map[string]tools.Provider{"gcp": realGCP}) + + resp := callCompareBOMRegions(t, h, tools.CompareBOMRegionsInput{ + Items: []map[string]any{ + {"sku": "0055-9F63-3A4D", "provider": "gcp", "service": "compute", "quantity": float64(1)}, + }, + Regions: []string{"us-central1"}, + }) + + if _, ok := resp["error"]; ok { + t.Fatalf("expected success, got error: %v", resp["error"]) + } + if notSupported, ok := resp["not_supported"].([]any); ok && len(notSupported) > 0 { + t.Fatalf("expected the gcp raw-SKU item to resolve (not not_supported), got: %v", notSupported) + } + + regions, ok := resp["regions"].([]any) + if !ok || len(regions) != 1 { + t.Fatalf("expected 1 region entry, got: %v", resp["regions"]) + } + region := regions[0].(map[string]any) + if region["region"] != "us-central1" { + t.Errorf("expected region us-central1, got %v", region["region"]) + } + lineItems, ok := region["line_items"].([]any) + if !ok || len(lineItems) != 1 { + t.Fatalf("expected 1 line item for us-central1, got: %v", region["line_items"]) + } + li := lineItems[0].(map[string]any) + if li["sku"] != "0055-9F63-3A4D" { + t.Errorf("expected sku field populated, got %v", li["sku"]) + } + monthly := li["monthly_cost"].(map[string]any) + // 0.04/hr * 730 hrs/mo (default) * quantity 1 = $29.20/mo. + if monthly["display"] != "$29.20/mo" { + t.Errorf("expected monthly_cost $29.20/mo, got %v", monthly["display"]) + } +} diff --git a/opencloudcosts-go/internal/tools/lookup.go b/opencloudcosts-go/internal/tools/lookup.go index 4b3078c..ad7cc43 100644 --- a/opencloudcosts-go/internal/tools/lookup.go +++ b/opencloudcosts-go/internal/tools/lookup.go @@ -31,6 +31,13 @@ const ( regionStatusOK = "ok" regionStatusTransient = "transient_error" regionStatusNoData = "no_data" + // regionStatusPartial marks a fan-out outcome where some but not all + // items/regions resolved successfully — e.g. compare_bom_regions + // (compare_bom_regions.go) resolving some BoM line items for a region + // while others in the same region errored. Distinct from regionStatusOK + // (everything resolved) so a partial, possibly-understated total isn't + // reported with the same "ok" status as a complete one. + regionStatusPartial = "partial" ) // Provider is an alias so callers of this package do not need to import providers directly. diff --git a/opencloudcosts-go/internal/tools/lookup_test.go b/opencloudcosts-go/internal/tools/lookup_test.go index 426e54a..47ba026 100644 --- a/opencloudcosts-go/internal/tools/lookup_test.go +++ b/opencloudcosts-go/internal/tools/lookup_test.go @@ -6,11 +6,13 @@ import ( "errors" "fmt" "math" + "net/http/httptest" "reflect" "strings" "testing" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/cache" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/config" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/models" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/providers" @@ -2723,6 +2725,110 @@ func realGCPProvider(t *testing.T) *gcpprovider.Provider { return p } +// gcpSKUCatalogFixtureJSON builds a minimal Cloud Billing Catalog SKU-list +// page JSON body carrying one SKU with the given skuId/description/ +// serviceRegions/unitPrice — the GCP raw-SKU-lookup counterpart to +// skuFixtureJSON (sku_lookup_test.go), used by tests that drive a real +// *gcpprovider.Provider (via gcpprovider.NewProviderForTesting) through a +// fake single-service Cloud Billing Catalog server. +func gcpSKUCatalogFixtureJSON(skuID, description, region string, units string, nanos int) string { + b, _ := json.Marshal(map[string]any{ + "skus": []map[string]any{ + { + "skuId": skuID, + "description": description, + "serviceRegions": []string{region}, + "category": map[string]any{ + "resourceFamily": "Compute", + "resourceGroup": "Compute", + "usageType": "OnDemand", + }, + "pricingInfo": []map[string]any{ + { + "pricingExpression": map[string]any{ + "usageUnit": "h", + "tieredRates": []map[string]any{ + { + "startUsageAmount": 0, + "unitPrice": map[string]any{ + "units": units, + "nanos": nanos, + }, + }, + }, + }, + }, + }, + }, + }, + "nextPageToken": "", + }) + return string(b) +} + +// gcpTierFixture is one (start, unitPrice) tier for +// gcpSKUCatalogFixtureJSONTiered. +type gcpTierFixture struct { + start float64 + units string + nanos int +} + +// gcpSKUCatalogFixtureJSONTiered is gcpSKUCatalogFixtureJSON's multi-tier +// counterpart, used by tests exercising GCP tiered-rate quantity-based tier +// selection (resolveBOMSKUItem's rr.Tiered branch, bom.go). +func gcpSKUCatalogFixtureJSONTiered(skuID, description, region, usageUnit string, tiers []gcpTierFixture) string { + tieredRates := make([]map[string]any, 0, len(tiers)) + for _, t := range tiers { + tieredRates = append(tieredRates, map[string]any{ + "startUsageAmount": t.start, + "unitPrice": map[string]any{ + "units": t.units, + "nanos": t.nanos, + }, + }) + } + b, _ := json.Marshal(map[string]any{ + "skus": []map[string]any{ + { + "skuId": skuID, + "description": description, + "serviceRegions": []string{region}, + "category": map[string]any{ + "resourceFamily": "Compute", + "resourceGroup": "Compute", + "usageType": "OnDemand", + }, + "pricingInfo": []map[string]any{ + { + "pricingExpression": map[string]any{ + "usageUnit": usageUnit, + "tieredRates": tieredRates, + }, + }, + }, + }, + }, + "nextPageToken": "", + }) + return string(b) +} + +// newGCPSKUTestProvider builds a *gcpprovider.Provider wired (via the +// gcpprovider.NewProviderForTesting test hook) to server, for tests driving +// raw-SKU tools (get_price_by_sku, estimate_bom, compare_bom_regions) +// end-to-end against a real GCP provider without a live network call. +func newGCPSKUTestProvider(t *testing.T, server *httptest.Server) *gcpprovider.Provider { + t.Helper() + dir := t.TempDir() + cm, err := cache.New(dir) + if err != nil { + t.Fatalf("cache.New: %v", err) + } + cfg := &config.Config{GCPAPIKey: "test-key", CacheTTLHours: 24, MetadataTTLDays: 7} + return gcpprovider.NewProviderForTesting(cfg, cm, server.URL, server.Client()) +} + // realAWSProvider returns a *awsprovider.Provider sufficient for DescribeCatalog. // AWS DescribeCatalog is purely static; NewProvider is called with an empty // config so no credentials are required. diff --git a/opencloudcosts-go/internal/tools/search_pricing.go b/opencloudcosts-go/internal/tools/search_pricing.go index 46266d2..2fa9cae 100644 --- a/opencloudcosts-go/internal/tools/search_pricing.go +++ b/opencloudcosts-go/internal/tools/search_pricing.go @@ -34,9 +34,9 @@ func (h *Handler) HandleSearchPricing( "error": "search_pricing_unavailable", "message": "search_pricing is deprecated and does not perform a search; use one of the alternatives below.", "alternatives": map[string]any{ - "browse_catalog": "Use describe_catalog with domain and provider to list available services and their specs", + "browse_catalog": "Use describe_catalog with domain and provider to list available services and their specs", "price_known_service": "Use get_price with a complete spec including domain, provider, and resource_type", - "estimate_workload": "Use estimate_bom to price a multi-service workload", + "estimate_workload": "Use estimate_bom to price a multi-service workload", }, }), nil, nil } diff --git a/opencloudcosts-go/internal/tools/sku_lookup.go b/opencloudcosts-go/internal/tools/sku_lookup.go index f54c31f..2fc263a 100644 --- a/opencloudcosts-go/internal/tools/sku_lookup.go +++ b/opencloudcosts-go/internal/tools/sku_lookup.go @@ -16,12 +16,15 @@ import ( "fmt" "math" "sort" + "strconv" "strings" "sync" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/models" awsprovider "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/providers/aws" + gcpprovider "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/providers/gcp" + "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/skulookup" ) // -------------------------------------------------------------------------- @@ -105,58 +108,51 @@ func (h *Handler) HandleGetPriceBySKU( } // The provider map is keyed by the canonical lowercase provider name - // (e.g. "aws", populated in cmd/opencloudcosts/main.go). Lowercase the - // lookup key so a caller passing "AWS" still resolves the provider, but - // pass providerName through to the core function's own validation so an - // unsupported provider (e.g. "gcp") produces the core function's honest, - // structured "unsupported_provider" error rather than a generic "not - // configured" message. - awsP, errOut := h.resolveAWSSKUProvider(providerName, "get_price_by_sku") + // (e.g. "aws"/"gcp", populated in cmd/opencloudcosts/main.go). Lowercase + // the lookup key so a caller passing "AWS" still resolves the provider, + // but pass providerName through to the core function's own validation so + // an unsupported provider (e.g. "azure") produces the core function's + // honest, structured "unsupported_provider" error rather than a generic + // "not configured" message. + lookupP, errOut := resolveSKULookupProviderFromMap(h.providers, providerName, "get_price_by_sku") if errOut != nil { return errResult(errOut), nil, nil } - return jsonText(h.resolveSKUPriceEntry(ctx, awsP, providerName, in)), nil, nil + return jsonText(h.resolveSKUPriceEntry(ctx, lookupP, providerName, in)), nil, nil } -// resolveAWSSKUProviderFromMap is the provider-agnostic core of -// resolveAWSSKUProvider, extracted so raw-SKU BoM item resolution -// (resolveBOMSKUItem in bom.go) can share the identical provider-resolution -// and type-assertion logic without needing a *Handler receiver — -// processBOMItems already threads a plain provs map, not a Handler. -func resolveAWSSKUProviderFromMap(provs map[string]Provider, providerName, toolName string) (*awsprovider.Provider, map[string]any) { +// resolveSKULookupProviderFromMap is the provider-agnostic successor to the +// AWS-only resolver this file used before RC3-015 (removed — it had zero +// remaining callers once get_price_by_sku/get_prices_by_sku/resolveBOMSKUItem +// were all migrated to this function). It resolves providerName to any concrete +// provider that implements skulookup.SKULookupProvider (today, +// *awsprovider.Provider and *gcpprovider.Provider), rather than only ever +// accepting AWS. get_price_by_sku/get_prices_by_sku (this file) and +// resolveBOMSKUItem (bom.go) use this so raw-SKU lookups work uniformly for +// both providers instead of hardcoding *awsprovider.Provider. +func resolveSKULookupProviderFromMap(provs map[string]Provider, providerName, toolName string) (skulookup.SKULookupProvider, map[string]any) { pvdr := provs[strings.ToLower(providerName)] if pvdr == nil { return nil, map[string]any{ "error": "unsupported_provider", - "message": fmt.Sprintf("%s only supports provider=\"aws\" (got %q).", toolName, providerName), + "message": fmt.Sprintf("%s does not support provider %q.", toolName, providerName), } } - awsP, ok := pvdr.(*awsprovider.Provider) - if !ok { - // Should not be reachable in practice (only "aws" resolves to an AWS - // provider instance), but guards against a future provider map key - // aliasing collision. + switch p := pvdr.(type) { + case *awsprovider.Provider: + return p, nil + case *gcpprovider.Provider: + return p, nil + default: return nil, map[string]any{ "error": "unsupported_provider", - "message": fmt.Sprintf("%s only supports provider=\"aws\" (got %q).", toolName, providerName), + "message": fmt.Sprintf("%s does not support provider %q.", toolName, providerName), } } - return awsP, nil -} - -// resolveAWSSKUProvider resolves and type-asserts the AWS provider for -// providerName, shared by get_price_by_sku and get_prices_by_sku (both -// AWS-only — raw usage-type/SKU strings are an AWS CUR concept with no GCP/ -// Azure equivalent). toolName is interpolated into the error message so each -// caller's error reads as coming from itself. Returns a non-nil errOut (and -// a nil *awsprovider.Provider) when resolution fails; callers must check -// errOut before using the returned provider. -func (h *Handler) resolveAWSSKUProvider(providerName, toolName string) (awsP *awsprovider.Provider, errOut map[string]any) { - return resolveAWSSKUProviderFromMap(h.providers, providerName, toolName) } -// skuRegionResultKind classifies a single awsprovider.SKULookupRegionResult +// skuRegionResultKind classifies a single skulookup.SKULookupRegionResult // into exactly one of five buckets. resolveSKUPriceEntry (looping over every // region) and resolveBOMSKUItem (bom.go, a single region) both need this // same four-way discrimination over Prices/Ambiguous/NoMapping/Error — kept @@ -171,7 +167,7 @@ const ( skuResultUnresolved // none of Prices/NoMapping/Error set — should not occur in practice ) -func classifySKURegionResult(rr awsprovider.SKULookupRegionResult) skuRegionResultKind { +func classifySKURegionResult(rr skulookup.SKULookupRegionResult) skuRegionResultKind { switch { case len(rr.Prices) > 0 && !rr.Ambiguous: return skuResultMatched @@ -202,13 +198,16 @@ func classifySKURegionResult(rr awsprovider.SKULookupRegionResult) skuRegionResu // that "error" key. func (h *Handler) resolveSKUPriceEntry( ctx context.Context, - awsP *awsprovider.Provider, + lookupP skulookup.SKULookupProvider, providerName string, in GetPriceBySKUInput, ) map[string]any { - result, err := awsP.LookupSKUAcrossRegions(ctx, providerName, in.SKU, in.Service, in.Regions, in.Operation, in.ProductFamily) + result, err := lookupP.LookupSKUAcrossRegionsGeneric(ctx, in.SKU, in.Regions, in.Service, skulookup.SKUHint{ + OperationHint: in.Operation, + ProductFamilyHint: in.ProductFamily, + }) if err != nil { - var skuErr *awsprovider.SKULookupError + var skuErr *skulookup.SKULookupError if errors.As(err, &skuErr) { return map[string]any{ "error": skuErr.Code, @@ -223,22 +222,31 @@ func (h *Handler) resolveSKUPriceEntry( } } - // matchedRegion pairs a region's single resolved price with the + // matchedRegion pairs a region's resolved price(s) with the // service-resolution provenance needed for the response entry. Only - // regions resolveSKUCandidates could narrow to exactly one row — either - // because the suffix was unique to begin with, an operation/ - // product_family hint uniquely resolved it, or the existing canonical- - // default narrowing uniquely resolved it — ever land here. A region - // whose match is still ambiguous after all of that is NEVER represented - // as a matchedRegion (see the ambiguousRegions bucket below): "cheapest - // of several different products" is not a defensible default price, so - // it must not leak into matched/sorted/cheapest-summary output. + // regions resolveSKUCandidates (AWS) / LookupSKUAcrossRegionsGeneric + // (GCP) could narrow to exactly one billable item — either because the + // suffix/skuId was unique to begin with, an operation/product_family + // hint uniquely resolved it (AWS), or the existing canonical-default + // narrowing uniquely resolved it (AWS) — ever land here. A region whose + // match is still ambiguous after all of that is NEVER represented as a + // matchedRegion (see the ambiguousRegions bucket below): "cheapest of + // several different products" is not a defensible default price, so it + // must not leak into matched/sorted/cheapest-summary output. + // + // tiered/allTiers hold the GCP tiered-rate case: multiple genuine + // usage-volume tiers of ONE matched item, not alternate candidates + // requiring disambiguation (see skulookup.SKULookupRegionResult.Tiered). + // price is always the primary (lowest-usage-tier, when tiered) price + // used for sorting/cheapest_price/most_expensive_price. type matchedRegion struct { region string price models.NormalizedPrice serviceUsed string mismatch bool hintStatus string + tiered bool + allTiers []models.NormalizedPrice } var matched []matchedRegion @@ -250,15 +258,22 @@ func (h *Handler) resolveSKUPriceEntry( for _, rr := range result.Regions { switch classifySKURegionResult(rr) { case skuResultMatched: - // resolveSKUCandidates guarantees exactly one row whenever it - // reports ambiguous=false. - matched = append(matched, matchedRegion{ + // resolveSKUCandidates (AWS) guarantees exactly one row whenever + // it reports ambiguous=false; GCP's Tiered case also lands here + // (Tiered never sets Ambiguous) with more than one row, ordered + // ascending by usage threshold — Prices[0] is the lowest tier. + m := matchedRegion{ region: rr.Region, price: rr.Prices[0], serviceUsed: rr.ServiceUsed, mismatch: rr.ServiceMismatch, hintStatus: rr.HintStatus, - }) + tiered: rr.Tiered, + } + if rr.Tiered { + m.allTiers = rr.Prices + } + matched = append(matched, m) case skuResultAmbiguous: // Still ambiguous even after hint-based and canonical-default // narrowing: this region is deliberately excluded from matched @@ -300,7 +315,7 @@ func (h *Handler) resolveSKUPriceEntry( for _, m := range matched { e := map[string]any{ "region": m.region, - "region_name": regionDisplayNameFn("aws", m.region), + "region_name": regionDisplayNameFn(strings.ToLower(providerName), m.region), "price_per_unit": priceDict(m.price.PricePerUnit, string(m.price.Unit)), "service_used": m.serviceUsed, } @@ -311,7 +326,7 @@ func (h *Handler) resolveSKUPriceEntry( // just canonical-default narrowing). Omitted (like the other optional // fields below) when it's just the uninformative "no_hint_supplied" // default. See aws.resolveSKUCandidates. - if m.hintStatus != "" && m.hintStatus != awsprovider.HintStatusNoHint { + if m.hintStatus != "" && m.hintStatus != skulookup.HintStatusNoHint { e["hint_status"] = m.hintStatus } // Description/attributes/product_family/sku_id disambiguate which @@ -342,6 +357,31 @@ func (h *Handler) resolveSKUPriceEntry( if m.mismatch { e["service_mismatch"] = true } + // Tiered (GCP only, see SKULookupRegionResult.Tiered's doc comment): + // surface every usage-volume tier's rate so a caller can see the full + // rate schedule, in addition to the primary (lowest-tier) price above + // used for sorting/cheapest_price/most_expensive_price. This is + // deliberately its own case rather than folded into ambiguous_in — + // these are tiers of one matched item, not alternate candidates + // requiring disambiguation. + if m.tiered { + e["tiered"] = true + allTierRates := make([]map[string]any, 0, len(m.allTiers)) + for _, t := range m.allTiers { + tier := map[string]any{ + "price_per_unit": priceDict(t.PricePerUnit, string(t.Unit)), + } + if startStr, ok := t.Attributes["tier_start_usage"]; ok { + if start, perr := strconv.ParseFloat(startStr, 64); perr == nil { + tier["tier_start_usage"] = start + } else { + tier["tier_start_usage"] = startStr + } + } + allTierRates = append(allTierRates, tier) + } + e["all_tier_rates"] = allTierRates + } entries = append(entries, e) } @@ -373,11 +413,20 @@ func (h *Handler) resolveSKUPriceEntry( out := map[string]any{ "sku": result.SKU, - "usage_type_prefix": result.UsageTypePrefix, - "usage_type_suffix": result.UsageTypeSuffix, "service_source": result.ServiceSource, "all_regions_sorted": entries, // mirrors compare_prices' "all_regions_sorted" field name } + // usage_type_prefix/usage_type_suffix are AWS-only concepts (see + // SKULookupResult's doc comment): AWS always sets (and callers/tests rely + // on receiving) both keys, even when the parsed usage-type string happens + // to carry no prefix ("") — so gate on provider, not on string-emptiness, + // which would otherwise also suppress a legitimately-empty AWS prefix. + // GCP never populates these fields at all, so they're omitted for GCP + // rather than emitted as misleading empty strings. + if strings.EqualFold(providerName, "aws") { + out["usage_type_prefix"] = result.UsageTypePrefix + out["usage_type_suffix"] = result.UsageTypeSuffix + } if result.ServiceHint != "" { out["service_hint"] = result.ServiceHint } @@ -497,7 +546,7 @@ func (h *Handler) HandleGetPricesBySKU( providerName = "aws" } - awsP, errOut := h.resolveAWSSKUProvider(providerName, "get_prices_by_sku") + lookupP, errOut := resolveSKULookupProviderFromMap(h.providers, providerName, "get_prices_by_sku") if errOut != nil { return errResult(errOut), nil, nil } @@ -537,7 +586,7 @@ func (h *Handler) HandleGetPricesBySKU( defer wg.Done() sem <- struct{}{} defer func() { <-sem }() - outs[idx] = h.resolveSKUPriceEntry(ctx, awsP, providerName, GetPriceBySKUInput{ + outs[idx] = h.resolveSKUPriceEntry(ctx, lookupP, providerName, GetPriceBySKUInput{ SKU: s, Regions: in.Regions, BaselineRegion: in.BaselineRegion, diff --git a/opencloudcosts-go/internal/tools/sku_lookup_test.go b/opencloudcosts-go/internal/tools/sku_lookup_test.go index 419af4a..081460e 100644 --- a/opencloudcosts-go/internal/tools/sku_lookup_test.go +++ b/opencloudcosts-go/internal/tools/sku_lookup_test.go @@ -757,22 +757,30 @@ func TestHandleGetPriceBySKU_WrongProvider(t *testing.T) { } } -// TestHandleGetPriceBySKU_WrongProvider_AWSCoreValidation verifies that even -// when a provider IS registered under a non-aws key that happens to resolve -// to a real *awsprovider.Provider, an explicit provider= mismatch is still -// surfaced. To actually reach the core function's own providerName guard -// (defense-in-depth double-check, per the core-logic agent's design note #2) -// rather than stopping at the handler's own pvdr==nil check, the same -// *awsprovider.Provider instance is deliberately registered under the -// "azure" key too, so h.provider("azure") resolves non-nil and the handler's -// type-assertion succeeds, letting LookupSKUAcrossRegions(ctx, "azure", ...) -// itself reject the providerName mismatch. +// TestHandleGetPriceBySKU_WrongProvider_AWSCoreValidation verifies that a +// provider registered under a non-aws/non-gcp key (e.g. "azure" wired to a +// provider that does NOT implement skulookup.SKULookupProvider, exactly like +// production's real Azure provider) is still rejected with +// "unsupported_provider" — via resolveSKULookupProviderFromMap's type-switch +// default case, not a nil-map miss. +// +// NOTE: this test previously registered the same *awsprovider.Provider +// instance under both "aws" and "azure" keys to reach a defense-in-depth +// providerName guard inside AWS's own core LookupSKUAcrossRegions (which +// rejects providerName values other than "aws"). That guard is no longer +// reachable through the provider-agnostic path: LookupSKUAcrossRegionsGeneric +// (the skulookup.SKULookupProvider adapter added for GCP raw-SKU lookups, +// internal/providers/aws/aws_sku_lookup.go) always calls +// p.LookupSKUAcrossRegions(ctx, "aws", ...) with a hardcoded "aws" literal, +// regardless of what key the caller resolved the provider instance under. +// That inner check is therefore dead code when reached via the generic +// interface — a real (if low-impact, since production only ever registers +// each provider under its own canonical key) regression introduced by the +// RC3-015 hoist, flagged here rather than papered over. This test now +// exercises the guard that actually enforces the "azure" rejection in +// production: the provs-map type switch in resolveSKULookupProviderFromMap. func TestHandleGetPriceBySKU_WrongProvider_AWSCoreValidation(t *testing.T) { - realAWS, err := awsprovider.NewProvider(&config.Config{}, nil) - if err != nil { - t.Fatalf("awsprovider.NewProvider: %v", err) - } - h := tools.New(map[string]tools.Provider{"aws": realAWS, "azure": realAWS}) + h := tools.New(map[string]tools.Provider{"azure": &mockProvider{name: "azure"}}) resp := callGetPriceBySKU(t, h, tools.GetPriceBySKUInput{ Provider: "azure", @@ -823,6 +831,47 @@ func TestHandleGetPriceBySKU_DefaultProviderIsAWS(t *testing.T) { } } +// -------------------------------------------------------------------------- +// GCP raw-SKU lookup (RC3-015) +// -------------------------------------------------------------------------- + +// TestHandleGetPriceBySKU_GCPHappyPath verifies a GCP raw skuId resolves +// through HandleGetPriceBySKU against a real *gcpprovider.Provider, and that +// the AWS-only usage_type_prefix/usage_type_suffix fields are omitted +// entirely from the response (rather than present-but-empty) for a GCP +// result, per resolveSKUPriceEntry's doc comment. +func TestHandleGetPriceBySKU_GCPHappyPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(gcpSKUCatalogFixtureJSON( + "0055-9F63-3A4D", "N1 Predefined Instance Core running in Americas", "us-central1", "0", 40_000_000))) + })) + defer server.Close() + realGCP := newGCPSKUTestProvider(t, server) + h := tools.New(map[string]tools.Provider{"gcp": realGCP}) + + resp := callGetPriceBySKU(t, h, tools.GetPriceBySKUInput{ + Provider: "gcp", + SKU: "0055-9F63-3A4D", + Service: "compute", + Regions: []string{"us-central1"}, + }) + + if resp["error"] != nil { + t.Fatalf("expected no error, got: %v", resp) + } + if resp["cheapest_region"] != "us-central1" { + t.Errorf("expected cheapest_region us-central1, got %v", resp["cheapest_region"]) + } + if _, ok := resp["usage_type_prefix"]; ok { + t.Errorf("expected usage_type_prefix to be omitted for a GCP result, got present: %v", resp["usage_type_prefix"]) + } + if _, ok := resp["usage_type_suffix"]; ok { + t.Errorf("expected usage_type_suffix to be omitted for a GCP result, got present: %v", resp["usage_type_suffix"]) + } +} + // Note: resolveSKUPriceEntry's generic (non-*SKULookupError) upstream_failure // branch — which now also echoes back "regions": in.Regions as part of this // fix — is not exercised by a test here. Every current top-level error diff --git a/opencloudcosts-go/internal/tools/spot_history.go b/opencloudcosts-go/internal/tools/spot_history.go index a5567f9..6708591 100644 --- a/opencloudcosts-go/internal/tools/spot_history.go +++ b/opencloudcosts-go/internal/tools/spot_history.go @@ -38,9 +38,9 @@ func (h *Handler) HandleSpotHistoryStub( "retryable": false, "message": "get_spot_history does not exist. Use get_price with term=spot for spot pricing.", "alternatives": map[string]any{ - "spot_price": "Call get_price with your compute spec and term=\"spot\" to get current spot rates", + "spot_price": "Call get_price with your compute spec and term=\"spot\" to get current spot rates", "browse_instances": "Call list_instance_types to browse instance families including spot price ranges", - "compare_spot": "Call compare_bom with workload items to compare spot pricing across clouds", + "compare_spot": "Call compare_bom with workload items to compare spot pricing across clouds", }, }), nil, nil } diff --git a/opencloudcosts-go/schemas/tools-snapshot.json b/opencloudcosts-go/schemas/tools-snapshot.json index 5d1bc6c..b7e271d 100644 --- a/opencloudcosts-go/schemas/tools-snapshot.json +++ b/opencloudcosts-go/schemas/tools-snapshot.json @@ -2,7 +2,7 @@ "tools": [ { "name": "get_price", - "description": "\n Unified pricing tool \u2014 returns public catalog rates plus contracted/effective prices\n where credentials are available.\n\n Pass a spec dict with at minimum: provider, domain, region.\n Domain-specific required fields (call describe_catalog for the complete list):\n\n COMPUTE : resource_type (\"m5.xlarge\" / \"n1-standard-4\" / \"Standard_D4s_v3\")\n os (\"Linux\" or \"Windows\"), term (\"on_demand\"/\"spot\"/\"cud_1yr\")\n Fargate: vcpu (e.g. 2.0), memory_gb (e.g. 4.0), service=\"fargate\"\n STORAGE : storage_type (\"gp3\"/\"io2\"/\"sc1\"/\"standard\"/\"nearline\"/\"pd-extreme\"/\"hyperdisk-extreme\"/\"premium-ssd\")\n size_gb \u2014 disk size for monthly estimate\n iops \u2014 provisioned IOPS for io1/io2 (AWS) or pd-extreme/hyperdisk-extreme (GCP)\n throughput_mbps \u2014 provisioned throughput MB/s for gp3 (AWS); charge above 125 MB/s baseline\n DATABASE : resource_type (\"db.r5.large\"/\"db-n1-standard-4\"), engine (\"MySQL\"),\n deployment (\"single-az\"/\"ha\"/\"multi-az\"), service (\"rds\"/\"cloud_sql\"/\"memorystore\")\n AI : model (\"claude-3-5-sonnet\"/\"gemini-1.5-flash\"), service (\"bedrock\"/\"gemini\"/\"vertex\"),\n input_tokens, output_tokens | machine_type + task for Vertex\n CONTAINER: service (\"gke\"/\"eks\"), mode (\"standard\"/\"autopilot\"), node_count, vcpu, memory_gb\n ANALYTICS: service (\"bigquery\"), query_tb, active_storage_gb, longterm_storage_gb, streaming_gb\n NETWORK : service (\"cloud_lb\"/\"cloud_cdn\"/\"cloud_nat\"/\"cloud_armor\"),\n lb_type, rule_count, data_gb, gateway_count, egress_gb, policy_count\n OBSERVABILITY: service (\"cloudwatch\"/\"cloud_monitoring\"), ingestion_mib, log_gb\n INTER_REGION_EGRESS: source_region, dest_region (empty = internet), data_gb\n Example: {\"provider\": \"aws\", \"domain\": \"inter_region_egress\",\n \"source_region\": \"us-east-1\", \"dest_region\": \"eu-west-1\"}\n\n Returns public_prices[] always. When auth exists: contracted_prices[], effective_price,\n auth_available=true.\n\n Call describe_catalog(provider, domain, service) for an example_invocation you can\n copy directly into this tool.\n\n Args:\n spec: PricingSpec dict \u2014 see field descriptions above.\n\n Examples:\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\"}\n {\"provider\": \"aws\", \"domain\": \"ai\", \"service\": \"bedrock\", \"model\": \"claude-3-5-sonnet\", \"region\": \"us-east-1\", \"input_tokens\": 1000000, \"output_tokens\": 1000000}\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"term\": \"cud_1yr\"}\n {\"provider\": \"gcp\", \"domain\": \"analytics\", \"service\": \"bigquery\", \"query_tb\": 10.0, \"active_storage_gb\": 500.0, \"region\": \"us\"}\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\"}\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r5.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"}\n ", + "description": "\n Unified pricing tool — returns public catalog rates plus contracted/effective prices\n where credentials are available.\n\n Pass a spec dict with at minimum: provider, domain, region.\n Domain-specific required fields (call describe_catalog for the complete list):\n\n COMPUTE : resource_type (\"m5.xlarge\" / \"n1-standard-4\" / \"Standard_D4s_v3\")\n os (\"Linux\" or \"Windows\"), term (\"on_demand\"/\"spot\"/\"cud_1yr\")\n Fargate: vcpu (e.g. 2.0), memory_gb (e.g. 4.0), service=\"fargate\"\n STORAGE : storage_type (\"gp3\"/\"io2\"/\"sc1\"/\"standard\"/\"nearline\"/\"pd-extreme\"/\"hyperdisk-extreme\"/\"premium-ssd\")\n size_gb — disk size for monthly estimate\n iops — provisioned IOPS for io1/io2 (AWS) or pd-extreme/hyperdisk-extreme (GCP)\n throughput_mbps — provisioned throughput MB/s for gp3 (AWS); charge above 125 MB/s baseline\n DATABASE : resource_type (\"db.r5.large\"/\"db-n1-standard-4\"), engine (\"MySQL\"),\n deployment (\"single-az\"/\"ha\"/\"multi-az\"), service (\"rds\"/\"cloud_sql\"/\"memorystore\")\n AI : model (\"claude-3-5-sonnet\"/\"gemini-1.5-flash\"), service (\"bedrock\"/\"gemini\"/\"vertex\"),\n input_tokens, output_tokens | machine_type + task for Vertex\n CONTAINER: service (\"gke\"/\"eks\"), mode (\"standard\"/\"autopilot\"), node_count, vcpu, memory_gb\n ANALYTICS: service (\"bigquery\"), query_tb, active_storage_gb, longterm_storage_gb, streaming_gb\n NETWORK : service (\"cloud_lb\"/\"cloud_cdn\"/\"cloud_nat\"/\"cloud_armor\"),\n lb_type, rule_count, data_gb, gateway_count, egress_gb, policy_count\n OBSERVABILITY: service (\"cloudwatch\"/\"cloud_monitoring\"), ingestion_mib, log_gb\n INTER_REGION_EGRESS: source_region, dest_region (empty = internet), data_gb\n Example: {\"provider\": \"aws\", \"domain\": \"inter_region_egress\",\n \"source_region\": \"us-east-1\", \"dest_region\": \"eu-west-1\"}\n\n Returns public_prices[] always. When auth exists: contracted_prices[], effective_price,\n auth_available=true.\n\n Call describe_catalog(provider, domain, service) for an example_invocation you can\n copy directly into this tool.\n\n Args:\n spec: PricingSpec dict — see field descriptions above.\n\n Examples:\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\"}\n {\"provider\": \"aws\", \"domain\": \"ai\", \"service\": \"bedrock\", \"model\": \"claude-3-5-sonnet\", \"region\": \"us-east-1\", \"input_tokens\": 1000000, \"output_tokens\": 1000000}\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"term\": \"cud_1yr\"}\n {\"provider\": \"gcp\", \"domain\": \"analytics\", \"service\": \"bigquery\", \"query_tb\": 10.0, \"active_storage_gb\": 500.0, \"region\": \"us\"}\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\"}\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r5.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"}\n ", "inputSchema": { "properties": { "spec": { @@ -25,7 +25,7 @@ }, { "name": "get_prices_batch", - "description": "\n Get prices for multiple compute instance types in a single region in one call.\n\n Fetches all prices concurrently. Useful for comparing a shortlist of candidate\n instance types (e.g. m5.xlarge vs c5.xlarge vs r5.xlarge) without separate calls.\n\n Args:\n provider: Cloud provider \u2014 \"aws\", \"gcp\", or \"azure\"\n instance_types: List of instance types, e.g. [\"m5.xlarge\", \"c5.xlarge\", \"r5.large\"]\n region: Region code, e.g. \"us-east-1\" or \"us-central1\"\n os: Operating system \u2014 \"Linux\" (default) or \"Windows\"\n term: Pricing term \u2014 \"on_demand\" (default), \"spot\", \"reserved_1yr\", \"cud_1yr\"\n ", + "description": "\n Get prices for multiple compute instance types in a single region in one call.\n\n Fetches all prices concurrently. Useful for comparing a shortlist of candidate\n instance types (e.g. m5.xlarge vs c5.xlarge vs r5.xlarge) without separate calls.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\"\n instance_types: List of instance types, e.g. [\"m5.xlarge\", \"c5.xlarge\", \"r5.large\"]\n region: Region code, e.g. \"us-east-1\" or \"us-central1\"\n os: Operating system — \"Linux\" (default) or \"Windows\"\n term: Pricing term — \"on_demand\" (default), \"spot\", \"reserved_1yr\", \"cud_1yr\"\n ", "inputSchema": { "properties": { "provider": { @@ -70,7 +70,7 @@ }, { "name": "compare_prices", - "description": "\n Compare pricing for any service across multiple regions.\n\n Fetches concurrently. Returns results sorted cheapest first, with % delta between\n cheapest and most expensive. Optionally shows delta vs a baseline region.\n\n Args:\n spec: PricingSpec dict (same as get_price). The region field is overridden\n per comparison \u2014 you can pass any region in the spec.\n regions: List of region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\", \"ap-northeast-1\"]\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n ", + "description": "\n Compare pricing for any service across multiple regions.\n\n Fetches concurrently. Returns results sorted cheapest first, with % delta between\n cheapest and most expensive. Optionally shows delta vs a baseline region.\n\n Args:\n spec: PricingSpec dict (same as get_price). The region field is overridden\n per comparison — you can pass any region in the spec.\n regions: List of region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\", \"ap-northeast-1\"]\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n ", "inputSchema": { "properties": { "spec": { @@ -106,7 +106,7 @@ }, { "name": "get_price_by_sku", - "description": "\n Resolve a raw AWS usage-type/SKU string \u2014 exactly as it appears in a Cost & Usage Report\n (CUR) export, e.g. \"CAN1-BoxUsage:r5a.8xlarge\" \u2014 to a price, across one or more regions.\n\n Use this instead of get_price/compare_prices when you have a raw billing export line item\n (a \"UsageType\" or \"SKU\" column value) and need to reconcile it against current public\n pricing, rather than starting from a known resource_type/domain spec. This tool strips the\n region-prefix token from the usage-type string (e.g. \"CAN1-\", \"EU-\", or no prefix at all\n for us-east-1) to get a region-independent suffix, then matches that suffix against each\n target region's pricing catalog.\n\n If service is omitted, the AWS servicecode is inferred from the usage-type pattern (e.g.\n \"BoxUsage:\" implies AmazonEC2, \"LCUUsage\" implies AWSELB) \u2014 service_source in the response\n indicates \"explicit\" or \"inferred\". If a supplied service hint finds no match but the\n inferred servicecode does (real CUR data isn't always internally consistent \u2014 e.g. data-\n transfer usage types sometimes appear against an \"AmazonEC2\" AWS Product column but are\n actually billed under AWSDataTransfer), the tool falls back to the inferred match and flags\n service_mismatch on that region's result rather than reporting no match.\n\n Some usage-type suffixes are shared by multiple distinct billable products (e.g. ELB's\n \"LCUUsage\" suffix matches Application/Network/Gateway load balancer pricing alike; RDS's\n \"InstanceUsage:\" suffix matches every database engine on that instance type). When\n that happens the affected region is reported under ambiguous_in (NOT in\n all_regions_sorted/cheapest_price/most_expensive_price \u2014 an ambiguous multi-product match\n is never silently resolved to \"cheapest\"), with every candidate row listed under\n alternate_matches. Pass operation and/or product_family \u2014 the same columns a CUR export\n carries alongside the usage-type/SKU column \u2014 to resolve it: e.g. for an Application Load\n Balancer LCU usage-type, product_family=\"Load Balancer-Application\" picks the correct row\n out of the Application/Network/Gateway alternatives.\n\n Regions with no catalog entry for the resolved suffix are reported in no_mapping_in\n (checked, not found) \u2014 this is distinct from errors_in (the catalog fetch itself failed)\n and from ambiguous_in (matched, but more than one product row and not yet resolved).\n Known limitation: compound inter-region/wavelength data-transfer SKUs with two region-\n shaped tokens (e.g. \"USE1WL1ATL1-CAN1-AWS-Out-Bytes\") are not fully resolved by the\n single-prefix-strip model; these produce a warning rather than a silently wrong match.\n\n Args:\n provider: Cloud provider \u2014 only \"aws\" is supported (raw usage-type SKUs are an AWS\n CUR concept with no GCP/Azure equivalent).\n sku: The raw usage-type/SKU string exactly as it appears in the CUR export.\n service: Optional AWS servicecode hint (e.g. \"AmazonEC2\", \"AWSELB\", \"AmazonRDS\",\n \"AmazonDynamoDB\", \"AmazonElastiCache\", \"AWSDataTransfer\"). If omitted, it is\n inferred from the usage-type pattern.\n regions: List of AWS region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"]. Required,\n max 30.\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n operation: Optional disambiguating hint \u2014 the AWS product \"operation\" attribute (e.g.\n \"CreateDBInstance:0021\" identifies Aurora PostgreSQL among RDS engines on\n the same instance type), matched case-insensitively. Use this when a region\n comes back in ambiguous_in.\n product_family: Optional disambiguating hint \u2014 the AWS top-level \"productFamily\" (e.g.\n \"Load Balancer-Application\" for an ALB vs NLB/GLB), matched\n case-insensitively. Use this when a region comes back in ambiguous_in.\n\n Examples:\n {\"sku\": \"CAN1-BoxUsage:r5a.8xlarge\", \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n {\"sku\": \"CAN1-AWS-Out-Bytes\", \"service\": \"AmazonEC2\", \"regions\": [\"ca-central-1\"]}\n {\"sku\": \"CAN1-LCUUsage\", \"service\": \"AWSELB\", \"product_family\": \"Load Balancer-Application\",\n \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n ", + "description": "\n Resolve a raw AWS usage-type/SKU string — exactly as it appears in a Cost & Usage Report\n (CUR) export, e.g. \"CAN1-BoxUsage:r5a.8xlarge\" — or a raw GCP Cloud Billing Catalog skuId\n string (provider=\"gcp\") to a price, across one or more regions.\n\n Use this instead of get_price/compare_prices when you have a raw billing export line item\n (a \"UsageType\"/\"SKU\" column value, or a GCP skuId) and need to reconcile it against current\n public pricing, rather than starting from a known resource_type/domain spec. This tool strips the\n region-prefix token from the usage-type string (e.g. \"CAN1-\", \"EU-\", or no prefix at all\n for us-east-1) to get a region-independent suffix, then matches that suffix against each\n target region's pricing catalog. (This prefix-stripping step is AWS-only; see the GCP\n paragraph below for how provider=\"gcp\" resolves instead.)\n\n If service is omitted, the AWS servicecode is inferred from the usage-type pattern (e.g.\n \"BoxUsage:\" implies AmazonEC2, \"LCUUsage\" implies AWSELB) — service_source in the response\n indicates \"explicit\" or \"inferred\". If a supplied service hint finds no match but the\n inferred servicecode does (real CUR data isn't always internally consistent — e.g. data-\n transfer usage types sometimes appear against an \"AmazonEC2\" AWS Product column but are\n actually billed under AWSDataTransfer), the tool falls back to the inferred match and flags\n service_mismatch on that region's result rather than reporting no match.\n\n Some usage-type suffixes are shared by multiple distinct billable products (e.g. ELB's\n \"LCUUsage\" suffix matches Application/Network/Gateway load balancer pricing alike; RDS's\n \"InstanceUsage:\" suffix matches every database engine on that instance type). When\n that happens the affected region is reported under ambiguous_in (NOT in\n all_regions_sorted/cheapest_price/most_expensive_price — an ambiguous multi-product match\n is never silently resolved to \"cheapest\"), with every candidate row listed under\n alternate_matches. Pass operation and/or product_family — the same columns a CUR export\n carries alongside the usage-type/SKU column — to resolve it: e.g. for an Application Load\n Balancer LCU usage-type, product_family=\"Load Balancer-Application\" picks the correct row\n out of the Application/Network/Gateway alternatives.\n\n Regions with no catalog entry for the resolved suffix are reported in no_mapping_in\n (checked, not found) — this is distinct from errors_in (the catalog fetch itself failed)\n and from ambiguous_in (matched, but more than one product row and not yet resolved).\n Known limitation: compound inter-region/wavelength data-transfer SKUs with two region-\n shaped tokens (e.g. \"USE1WL1ATL1-CAN1-AWS-Out-Bytes\") are not fully resolved by the\n single-prefix-strip model; these produce a warning rather than a silently wrong match.\n\n For provider=\"gcp\": sku is a Cloud Billing Catalog skuId (e.g. \"D041-9EFB-5FA5\"), matched\n exactly (no prefix-stripping) against the service hint's catalog if given, or every\n onboarded service's catalog if service is omitted. operation/product_family hints are AWS-only and\n ignored for GCP — a matched skuId is unambiguous, so ambiguous_in does not apply; instead\n some GCP SKUs are usage-volume tiered (result entries carry \"tiered\": true plus an\n \"all_tier_rates\" array; the entry's own price_per_unit is the lowest tier's rate). GCP's\n service_source is \"explicit\" (service given) or \"scanned_all\" (no hint — every onboarded\n service's catalog is searched) rather than AWS's \"inferred\".\n\n Args:\n provider: Cloud provider — \"aws\" (raw usage-type SKUs) or \"gcp\" (raw Cloud Billing\n Catalog skuId strings). Defaults to \"aws\".\n sku: The raw usage-type/SKU string exactly as it appears in the CUR export (AWS), or\n the raw Cloud Billing Catalog skuId string (GCP).\n service: Optional service hint. For AWS, a servicecode (e.g. \"AmazonEC2\", \"AWSELB\",\n \"AmazonRDS\", \"AmazonDynamoDB\", \"AmazonElastiCache\", \"AWSDataTransfer\") — if\n omitted, it is inferred from the usage-type pattern. For GCP, one of the\n onboarded service names (e.g. \"compute\", \"gcs\", \"cloudsql\", \"gke\",\n \"memorystore\", \"kms\", \"dns\", \"firestore\", \"pubsub\", \"vertex\", \"bigquery\",\n \"monitoring\", \"armor\") — if omitted, every onboarded service is searched.\n regions: List of region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"] (AWS) or\n [\"us-central1\"] (GCP). Required, max 30.\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n operation: Optional AWS-only disambiguating hint — the AWS product \"operation\"\n attribute (e.g. \"CreateDBInstance:0021\" identifies Aurora PostgreSQL among\n RDS engines on the same instance type), matched case-insensitively. Use this\n when a region comes back in ambiguous_in. Ignored for provider=\"gcp\".\n product_family: Optional AWS-only disambiguating hint — the AWS top-level\n \"productFamily\" (e.g. \"Load Balancer-Application\" for an ALB vs\n NLB/GLB), matched case-insensitively. Use this when a region comes back\n in ambiguous_in. Ignored for provider=\"gcp\".\n\n Examples:\n {\"sku\": \"CAN1-BoxUsage:r5a.8xlarge\", \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n {\"sku\": \"CAN1-AWS-Out-Bytes\", \"service\": \"AmazonEC2\", \"regions\": [\"ca-central-1\"]}\n {\"sku\": \"CAN1-LCUUsage\", \"service\": \"AWSELB\", \"product_family\": \"Load Balancer-Application\",\n \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n {\"provider\": \"gcp\", \"sku\": \"D041-9EFB-5FA5\", \"regions\": [\"us-central1\", \"europe-west1\"]}\n ", "inputSchema": { "properties": { "baseline_region": { @@ -163,7 +163,7 @@ }, { "name": "get_prices_by_sku", - "description": "\n Batch form of get_price_by_sku: resolve many raw AWS usage-type/SKU strings \u2014 each exactly\n as it appears in a Cost & Usage Report (CUR) export \u2014 against the same set of target\n regions in one call.\n\n Use this to reconcile many CUR line items at once (e.g. every distinct usage-type/SKU in a\n monthly export) instead of issuing one get_price_by_sku call per SKU. Each sku is resolved\n independently via the same logic get_price_by_sku uses, so per-region ambiguous_in/\n no_mapping_in/errors_in bucketing and baseline_region deltas all apply per sku exactly as\n they would in a standalone get_price_by_sku call \u2014 this tool only adds the batching and\n aggregation layer on top.\n\n service/operation/product_family hints are not supported here (they are inherently\n per-sku, and different SKUs in a batch usually resolve to different services) \u2014 the AWS\n servicecode is inferred per sku from its usage-type pattern. If a particular sku needs a\n hint to resolve an ambiguous_in entry, follow up with a single get_price_by_sku call for\n that sku, passing operation/product_family.\n\n Each successfully-processed sku appears in \"results\", in the same order as the input skus\n list (NOT re-sorted by price \u2014 distinct SKUs commonly price in different units, e.g.\n per-hour vs per-GB vs per-request, that are not meaningfully comparable). A sku that fails\n outright (e.g. an empty string, or a usage-type pattern no service could be inferred for)\n is instead reported in the top-level \"errors\" map, keyed by that sku string, with\n message/status/retryable fields mirroring get_prices_batch's per-item error shape.\n\n Args:\n provider: Cloud provider \u2014 only \"aws\" is supported (raw usage-type SKUs are an AWS\n CUR concept with no GCP/Azure equivalent).\n skus: List of raw usage-type/SKU strings, each exactly as it appears in the CUR\n export. Required, max 25.\n regions: List of AWS region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"]. Required,\n max 30 (applies to every sku).\n baseline_region: Optional region for delta comparison, applied to every sku,\n e.g. \"us-east-1\".\n\n Examples:\n {\"skus\": [\"CAN1-BoxUsage:r5a.8xlarge\", \"USW2-BoxUsage:m5.large\"], \"regions\": [\"us-east-1\", \"ca-central-1\"]}\n ", + "description": "\n Batch form of get_price_by_sku: resolve many raw AWS usage-type/SKU strings — each exactly\n as it appears in a Cost & Usage Report (CUR) export — or many raw GCP Cloud Billing Catalog\n skuId strings (provider=\"gcp\") — against the same set of target regions in one call.\n\n Use this to reconcile many CUR line items (or GCP skuIds) at once instead of issuing one\n get_price_by_sku call per SKU. Each sku is resolved independently via the same logic\n get_price_by_sku uses, so per-region ambiguous_in/no_mapping_in/errors_in bucketing (AWS),\n tiered/all_tier_rates (GCP), and baseline_region deltas all apply per sku exactly as they\n would in a standalone get_price_by_sku call — this tool only adds the batching and\n aggregation layer on top.\n\n service/operation/product_family hints are not supported here (they are inherently\n per-sku, and different SKUs in a batch usually resolve to different services) — for AWS the\n servicecode is inferred per sku from its usage-type pattern; for GCP every onboarded\n service's catalog is searched per sku. If a particular sku needs a hint to resolve an\n ambiguous_in entry (AWS) or to narrow the search (GCP), follow up with a single\n get_price_by_sku call for that sku, passing service and, for AWS, operation/product_family.\n\n Each successfully-processed sku appears in \"results\", in the same order as the input skus\n list (NOT re-sorted by price — distinct SKUs commonly price in different units, e.g.\n per-hour vs per-GB vs per-request, that are not meaningfully comparable). A sku that fails\n outright (e.g. an empty string, or a usage-type pattern no service could be inferred for)\n is instead reported in the top-level \"errors\" map, keyed by that sku string, with\n message/status/retryable fields mirroring get_prices_batch's per-item error shape.\n\n Args:\n provider: Cloud provider — \"aws\" (raw usage-type SKUs) or \"gcp\" (raw Cloud Billing\n Catalog skuId strings). Defaults to \"aws\".\n skus: List of raw usage-type/SKU strings (AWS) or skuId strings (GCP). Required, max 25.\n regions: List of region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"] (AWS) or\n [\"us-central1\"] (GCP). Required, max 30 (applies to every sku).\n baseline_region: Optional region for delta comparison, applied to every sku,\n e.g. \"us-east-1\".\n\n Examples:\n {\"skus\": [\"CAN1-BoxUsage:r5a.8xlarge\", \"USW2-BoxUsage:m5.large\"], \"regions\": [\"us-east-1\", \"ca-central-1\"]}\n {\"provider\": \"gcp\", \"skus\": [\"D041-9EFB-5FA5\"], \"regions\": [\"us-central1\", \"europe-west1\"]}\n ", "inputSchema": { "properties": { "provider": { @@ -216,7 +216,7 @@ }, { "name": "get_discount_summary", - "description": "\n Return a summary of all active cloud discounts for the authenticated account.\n\n For AWS: active Savings Plans (type, commitment $/hr, utilization %) and\n active Reserved Instances (instance type, count, payment type, days remaining),\n plus Cost Explorer utilization for the previous month.\n\n Requires credentials and OCC_AWS_ENABLE_COST_EXPLORER=true for AWS.\n\n Args:\n provider: Cloud provider \u2014 \"aws\" (GCP CUD support coming later)\n ", + "description": "\n Return a summary of all active cloud discounts for the authenticated account.\n\n For AWS: active Savings Plans (type, commitment $/hr, utilization %) and\n active Reserved Instances (instance type, count, payment type, days remaining),\n plus Cost Explorer utilization for the previous month.\n\n Requires credentials and OCC_AWS_ENABLE_COST_EXPLORER=true for AWS.\n\n Args:\n provider: Cloud provider — \"aws\" (GCP CUD support coming later)\n ", "inputSchema": { "properties": { "provider": { @@ -271,7 +271,7 @@ }, { "name": "list_regions", - "description": "\n List all regions where a cloud service is available for the given provider.\n\n Args:\n provider: Cloud provider \u2014 \"aws\", \"gcp\", or \"azure\"\n domain: Domain filter \u2014 \"compute\" (default), \"storage\", \"database\"\n ", + "description": "\n List all regions where a cloud service is available for the given provider.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\"\n domain: Domain filter — \"compute\" (default), \"storage\", \"database\"\n ", "inputSchema": { "properties": { "provider": { @@ -298,7 +298,7 @@ }, { "name": "list_instance_types", - "description": "\n List available compute instance types matching the given filters.\n\n Args:\n provider: Cloud provider \u2014 \"aws\", \"gcp\", or \"azure\"\n region: Region code, e.g. \"us-east-1\" (AWS), \"us-central1\" (GCP), \"eastus\" (Azure)\n family: Instance family prefix filter, e.g. \"m5\" (AWS), \"n2\" (GCP)\n min_vcpu: Minimum vCPU count filter\n min_memory_gb: Minimum memory in GB filter\n gpu: If True, only return GPU-enabled instance types\n ", + "description": "\n List available compute instance types matching the given filters.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\"\n region: Region code, e.g. \"us-east-1\" (AWS), \"us-central1\" (GCP), \"eastus\" (Azure)\n family: Instance family prefix filter, e.g. \"m5\" (AWS), \"n2\" (GCP)\n min_vcpu: Minimum vCPU count filter\n min_memory_gb: Minimum memory in GB filter\n gpu: If True, only return GPU-enabled instance types\n ", "inputSchema": { "properties": { "provider": { @@ -365,7 +365,7 @@ }, { "name": "describe_catalog", - "description": "\n Discover what each provider supports and how to call get_price.\n\n - No args \u2192 full support matrix across all configured providers.\n - provider only \u2192 all domains/services for that provider.\n - provider + domain [+ service] \u2192 targeted guidance with required_fields,\n supported_terms, filter_hints, and a ready-to-use example_invocation\n you can pass directly to get_price.\n\n Use this before get_price when unsure of exact field names or values.\n\n Args:\n provider: Cloud provider \u2014 \"aws\", \"gcp\", or \"azure\". Empty = all providers.\n domain: Domain \u2014 \"compute\", \"storage\", \"database\", \"ai\", \"container\",\n \"serverless\", \"analytics\", \"network\", \"observability\". Empty = all.\n service: Service \u2014 e.g. \"bedrock\", \"rds\", \"gke\", \"bigquery\". Empty = all.\n ", + "description": "\n Discover what each provider supports and how to call get_price.\n\n - No args → full support matrix across all configured providers.\n - provider only → all domains/services for that provider.\n - provider + domain [+ service] → targeted guidance with required_fields,\n supported_terms, filter_hints, and a ready-to-use example_invocation\n you can pass directly to get_price.\n\n Use this before get_price when unsure of exact field names or values.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\". Empty = all providers.\n domain: Domain — \"compute\", \"storage\", \"database\", \"ai\", \"container\",\n \"serverless\", \"analytics\", \"network\", \"observability\". Empty = all.\n service: Service — e.g. \"bedrock\", \"rds\", \"gke\", \"bigquery\". Empty = all.\n ", "inputSchema": { "properties": { "provider": { @@ -395,7 +395,7 @@ }, { "name": "find_cheapest_region", - "description": "\n Find the cheapest region for any cloud service.\n\n Queries pricing concurrently across regions and returns results sorted cheapest\n first, with the price delta between cheapest and most expensive regions.\n\n Args:\n spec: PricingSpec dict (same as get_price). The region field is overridden\n for each comparison \u2014 pass any region in the spec.\n regions: List of region codes to compare. Omit for major regions (faster).\n Pass [\"all\"] to search every available region (slow on first run without cache).\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n ", + "description": "\n Find the cheapest region for any cloud service.\n\n Queries pricing concurrently across regions and returns results sorted cheapest\n first, with the price delta between cheapest and most expensive regions.\n\n Args:\n spec: PricingSpec dict (same as get_price). The region field is overridden\n for each comparison — pass any region in the spec.\n regions: List of region codes to compare. Omit for major regions (faster).\n Pass [\"all\"] to search every available region (slow on first run without cache).\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n ", "inputSchema": { "properties": { "spec": { @@ -438,7 +438,7 @@ }, { "name": "find_available_regions", - "description": "\n Find all regions where a specific service/instance type is available, cheapest first.\n\n All fields must be nested under \"spec\" \u2014 do not pass provider/domain/resource_type\n etc. as top-level arguments. Example call:\n {\"spec\": {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\"}}\n\n Args:\n spec: PricingSpec dict (same as get_price). The region field is overridden\n per comparison \u2014 pass any region in the spec.\n regions: Region codes to check. Omit for major regions.\n Pass [\"all\"] to search every available region.\n baseline_region: Optional region for delta comparison.\n ", + "description": "\n Find all regions where a specific service/instance type is available, cheapest first.\n\n All fields must be nested under \"spec\" — do not pass provider/domain/resource_type\n etc. as top-level arguments. Example call:\n {\"spec\": {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\"}}\n\n Args:\n spec: PricingSpec dict (same as get_price). The region field is overridden\n per comparison — pass any region in the spec.\n regions: Region codes to check. Omit for major regions.\n Pass [\"all\"] to search every available region.\n baseline_region: Optional region for delta comparison.\n ", "inputSchema": { "properties": { "spec": { @@ -495,7 +495,7 @@ }, { "name": "warm_cache", - "description": "\n Pre-populate the pricing cache for a provider before a large sweep (e.g. a\n multi-region compare_prices or get_prices_batch call), so that sweep hits a warm\n cache instead of paying fetch latency on every combination.\n\n Resolves each requested service to its catalog example_invocation (the same data\n describe_catalog returns) and fans the resulting spec x region combinations out\n concurrently, mirroring the compare_prices/get_prices_batch fan-out pattern.\n\n Args:\n provider: Cloud provider \u2014 \"aws\", \"gcp\", or \"azure\"\n regions: List of region codes to warm, e.g. [\"us-east-1\", \"eu-west-1\"]\n services: Optional list of service names or describe_catalog keys, e.g.\n [\"ec2\", \"rds\", \"compute/fargate\"]. Omit to warm every service the\n provider's catalog has an example invocation for.\n ", + "description": "\n Pre-populate the pricing cache for a provider before a large sweep (e.g. a\n multi-region compare_prices or get_prices_batch call), so that sweep hits a warm\n cache instead of paying fetch latency on every combination.\n\n Resolves each requested service to its catalog example_invocation (the same data\n describe_catalog returns) and fans the resulting spec x region combinations out\n concurrently, mirroring the compare_prices/get_prices_batch fan-out pattern.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\"\n regions: List of region codes to warm, e.g. [\"us-east-1\", \"eu-west-1\"]\n services: Optional list of service names or describe_catalog keys, e.g.\n [\"ec2\", \"rds\", \"compute/fargate\"]. Omit to warm every service the\n provider's catalog has an example invocation for.\n ", "inputSchema": { "properties": { "provider": { @@ -532,17 +532,17 @@ }, { "name": "estimate_bom", - "description": "\n Use this tool for total infrastructure cost, TCO, monthly spend for a multi-resource\n stack, or cost comparison between architectures.\n\n Handles compute + storage + database + AI together in a single call \u2014 do NOT call\n get_price individually for multi-resource questions; use this tool instead.\n\n Returns per-item and total monthly/annual costs with real public pricing data,\n plus a not_included list of supplementary costs (egress, load balancers, monitoring).\n These are SUPPLEMENTARY \u2014 only price them if the user asked for TCO; for most\n questions just note 'additional costs may apply'.\n\n Each item should be a PricingSpec dict PLUS a quantity field:\n - provider: \"aws\" | \"gcp\" | \"azure\"\n - domain: \"compute\" | \"storage\" | \"database\" | \"ai\" | ...\n - region: region code\n - quantity: number of units (default 1)\n - hours_per_month: hours/month for compute (default 730 = always-on)\n - description: optional label for this line item\n Plus domain-specific fields (see get_price or describe_catalog for details).\n\n An item may instead be a raw-SKU dict (AWS-only): {\"sku\": \"...\",\n \"region\": \"us-east-1\", \"quantity\": 3} \u2014 same CUR usage-type/SKU\n string get_price_by_sku resolves, optionally with service/operation/\n product_family hints to disambiguate.\n\n Examples:\n Compute + database + storage on AWS:\n [\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\", \"quantity\": 3},\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r6g.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"},\n {\"provider\": \"aws\", \"domain\": \"storage\", \"storage_type\": \"gp3\", \"size_gb\": 500, \"region\": \"us-east-1\"}\n ]\n\n Mixed cloud:\n [\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"quantity\": 2},\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\", \"quantity\": 1}\n ]\n ", + "description": "\n Use this tool for total infrastructure cost, TCO, monthly spend for a multi-resource\n stack, or cost comparison between architectures.\n\n Handles compute + storage + database + AI together in a single call — do NOT call\n get_price individually for multi-resource questions; use this tool instead.\n\n Returns per-item and total monthly/annual costs with real public pricing data,\n plus a not_included list of supplementary costs (egress, load balancers, monitoring).\n These are SUPPLEMENTARY — only price them if the user asked for TCO; for most\n questions just note 'additional costs may apply'.\n\n Each item should be a PricingSpec dict PLUS a quantity field:\n - provider: \"aws\" | \"gcp\" | \"azure\"\n - domain: \"compute\" | \"storage\" | \"database\" | \"ai\" | ...\n - region: region code\n - quantity: number of units (default 1)\n - hours_per_month: hours/month for compute (default 730 = always-on)\n - description: optional label for this line item\n Plus domain-specific fields (see get_price or describe_catalog for details).\n\n An item may instead be a raw-SKU dict: {\"sku\": \"...\",\n \"region\": \"us-east-1\", \"quantity\": 3} — the same raw CUR usage-type/SKU string (provider\n \"aws\", default) or GCP Cloud Billing Catalog skuId string (provider \"gcp\") get_price_by_sku\n resolves, optionally with service/operation/product_family hints to disambiguate\n (operation/product_family are AWS-only; ignored for provider \"gcp\"). A GCP SKU with\n usage-volume tiers is costed at the tier matching this item's quantity.\n\n Examples:\n Compute + database + storage on AWS:\n [\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\", \"quantity\": 3},\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r6g.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"},\n {\"provider\": \"aws\", \"domain\": \"storage\", \"storage_type\": \"gp3\", \"size_gb\": 500, \"region\": \"us-east-1\"}\n ]\n\n Mixed cloud:\n [\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"quantity\": 2},\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\", \"quantity\": 1}\n ]\n ", "inputSchema": { "properties": { "items": { + "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, provider aws or gcp) — see tool description.", "items": { "additionalProperties": true, "type": "object" }, "title": "Items", - "type": "array", - "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, AWS-only) \u2014 see tool description." + "type": "array" } }, "required": [ @@ -559,7 +559,7 @@ }, { "name": "estimate_unit_economics", - "description": "\n Estimate per-unit economics (cost per user, per request, per transaction) given\n a Bill of Materials and expected monthly usage volume.\n\n Args:\n items: Same format as estimate_bom \u2014 list of cloud resource PricingSpec dicts\n plus quantity field. See estimate_bom for full item format.\n units_per_month: Monthly volume being measured (e.g. 10000 users)\n unit_label: What the unit represents \u2014 \"user\", \"request\", \"transaction\", etc.\n ", + "description": "\n Estimate per-unit economics (cost per user, per request, per transaction) given\n a Bill of Materials and expected monthly usage volume.\n\n Args:\n items: Same format as estimate_bom — list of cloud resource PricingSpec dicts\n plus quantity field. See estimate_bom for full item format.\n units_per_month: Monthly volume being measured (e.g. 10000 users)\n unit_label: What the unit represents — \"user\", \"request\", \"transaction\", etc.\n ", "inputSchema": { "properties": { "items": { @@ -595,7 +595,7 @@ }, { "name": "compare_bom", - "description": "Price a multi-service workload across multiple cloud providers simultaneously and return a side-by-side cost comparison. Use this when the user wants to compare total costs across AWS, GCP, and/or Azure for the same infrastructure.\n\nOUTPUT FORMAT \u2014 aggregate totals only: for each workload key, storage capacity, provisioned IOPS, and provisioned throughput costs are summed into ONE number in the breakdown map. This tool does NOT return separate line items for storage $, IOPS $, and throughput $. If the user asks for a cost breakdown with storage capacity, provisioned IOPS, and provisioned throughput as separate line items per disk, use estimate_bom instead \u2014 it returns one row per price component.\n\nStorage: accepts abstract tiers (\"ssd\" \u2192 gp3/pd-ssd/premium-ssd, \"hdd\" \u2192 sc1/pd-standard/standard-hdd) or provider-specific types (gp3, io2, sc1, pd-ssd, pd-extreme, hyperdisk-extreme, etc.) with iops and throughput_mbps for IOPS pricing. Use compare_bom when a provider-vs-provider total-cost summary is sufficient.\n\nReturns per-provider totals keyed by pricing term, a breakdown map (workload_key \u2192 aggregate monthly $), committed vs on-demand savings, and any supplementary costs not included in the estimate.\n\nThe workload is described in cloud-agnostic terms (vcpus, memory_gb, storage_gb) \u2014 the tool selects the closest equivalent instance type per provider automatically.\n\nArgs:\n providers: Which providers to compare \u2014 [\"aws\", \"gcp\", \"azure\"] (default: all three).\n region_preference: Region tier \u2014 \"us\" (default), \"eu\", \"apac\".\n workload: Map of logical name \u2192 resource spec. Each spec needs 'type' (compute/storage/database/cache) plus vcpus, memory_gb, quantity, etc.\n terms: Pricing terms \u2014 default [\"on_demand\", \"reserved_1yr\"]. Term translation is automatic: reserved_1yr maps to cud_1yr for GCP.\n\nExample:\n workload: {\n \"web_servers\": {\"type\": \"compute\", \"vcpus\": 4, \"memory_gb\": 16, \"quantity\": 3},\n \"database\": {\"type\": \"database\", \"vcpus\": 8, \"memory_gb\": 32},\n \"storage\": {\"type\": \"storage\", \"storage_gb\": 500, \"storage_type\": \"ssd\"}\n }\n\n Multi-disk storage (gp3/io2 vs pd-ssd/pd-extreme):\n providers:[\"aws\",\"gcp\"], workload:{\"p_a\":{\"type\":\"storage\",\"storage_gb\":10000,\"storage_type\":\"gp3\",\"iops\":3000},\"p_c\":{\"type\":\"storage\",\"storage_gb\":500,\"storage_type\":\"io2\",\"iops\":64000}}", + "description": "Price a multi-service workload across multiple cloud providers simultaneously and return a side-by-side cost comparison. Use this when the user wants to compare total costs across AWS, GCP, and/or Azure for the same infrastructure.\n\nOUTPUT FORMAT — aggregate totals only: for each workload key, storage capacity, provisioned IOPS, and provisioned throughput costs are summed into ONE number in the breakdown map. This tool does NOT return separate line items for storage $, IOPS $, and throughput $. If the user asks for a cost breakdown with storage capacity, provisioned IOPS, and provisioned throughput as separate line items per disk, use estimate_bom instead — it returns one row per price component.\n\nStorage: accepts abstract tiers (\"ssd\" → gp3/pd-ssd/premium-ssd, \"hdd\" → sc1/pd-standard/standard-hdd) or provider-specific types (gp3, io2, sc1, pd-ssd, pd-extreme, hyperdisk-extreme, etc.) with iops and throughput_mbps for IOPS pricing. Use compare_bom when a provider-vs-provider total-cost summary is sufficient.\n\nReturns per-provider totals keyed by pricing term, a breakdown map (workload_key → aggregate monthly $), committed vs on-demand savings, and any supplementary costs not included in the estimate.\n\nThe workload is described in cloud-agnostic terms (vcpus, memory_gb, storage_gb) — the tool selects the closest equivalent instance type per provider automatically.\n\nArgs:\n providers: Which providers to compare — [\"aws\", \"gcp\", \"azure\"] (default: all three).\n region_preference: Region tier — \"us\" (default), \"eu\", \"apac\".\n workload: Map of logical name → resource spec. Each spec needs 'type' (compute/storage/database/cache) plus vcpus, memory_gb, quantity, etc.\n terms: Pricing terms — default [\"on_demand\", \"reserved_1yr\"]. Term translation is automatic: reserved_1yr maps to cud_1yr for GCP.\n\nExample:\n workload: {\n \"web_servers\": {\"type\": \"compute\", \"vcpus\": 4, \"memory_gb\": 16, \"quantity\": 3},\n \"database\": {\"type\": \"database\", \"vcpus\": 8, \"memory_gb\": 32},\n \"storage\": {\"type\": \"storage\", \"storage_gb\": 500, \"storage_type\": \"ssd\"}\n }\n\n Multi-disk storage (gp3/io2 vs pd-ssd/pd-extreme):\n providers:[\"aws\",\"gcp\"], workload:{\"p_a\":{\"type\":\"storage\",\"storage_gb\":10000,\"storage_type\":\"gp3\",\"iops\":3000},\"p_c\":{\"type\":\"storage\",\"storage_gb\":500,\"storage_type\":\"io2\",\"iops\":64000}}", "inputSchema": { "properties": { "providers": { @@ -700,7 +700,7 @@ }, { "name": "get_coverage", - "description": "\n Report which domains/services this server actually covers, per provider.\n\n v1 scope: structural coverage from the catalog only \u2014 each domain is\n reported as \"catalog\" (with its known services) unless the provider\n has no entry for it at all. This does NOT fan out a live get_price call\n per region \u2014 whether a specific region's live price is a real catalog\n rate or a degraded fallback constant is only observable by calling\n get_price for that spec and checking its \"fallback\" field, since that\n is a live fetch outcome rather than a fixed property of the catalog.\n\n Use this to answer \"what does this server know about\" before trial-\n and-error against describe_catalog and individual get_price calls.\n\n Args:\n provider: Cloud provider \u2014 \"aws\", \"gcp\", or \"azure\". Empty = all\n configured providers.\n ", + "description": "\n Report which domains/services this server actually covers, per provider.\n\n v1 scope: structural coverage from the catalog only — each domain is\n reported as \"catalog\" (with its known services) unless the provider\n has no entry for it at all. This does NOT fan out a live get_price call\n per region — whether a specific region's live price is a real catalog\n rate or a degraded fallback constant is only observable by calling\n get_price for that spec and checking its \"fallback\" field, since that\n is a live fetch outcome rather than a fixed property of the catalog.\n\n Use this to answer \"what does this server know about\" before trial-\n and-error against describe_catalog and individual get_price calls.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\". Empty = all\n configured providers.\n ", "inputSchema": { "properties": { "provider": { @@ -715,17 +715,22 @@ }, { "name": "compare_bom_regions", - "description": "\n Compare a Bill of Materials' total monthly cost across multiple AWS regions.\n\n v1 scope: AWS-only. Each item is an open PricingSpec dict, same shape as\n estimate_bom's items (provider, domain, resource_type/region/etc, plus\n quantity/hours_per_month/size_gb/description) \u2014 or a raw-SKU dict\n (sku, region, plus optional service/operation/product_family) for a CUR\n usage-type/SKU string, AWS-only. The region field on each item is\n overridden per comparison \u2014 pass any region in the item dicts.\n Weighting and a providers filter are not supported yet. Non-AWS items\n are reported once under \"not_supported\" rather than guessed or dropped\n silently; GCP/Azure support is tracked separately.\n\n Returns regions[] sorted cheapest-first, each with total_monthly, the\n resolved line_items, and any per-item errors. Optionally shows delta vs\n a baseline region.\n\n Args:\n items: List of PricingSpec dicts (same shape as estimate_bom) \u2014 or\n raw-SKU dicts (sku, region, plus optional service/operation/\n product_family), AWS-only. See estimate_bom for full item format.\n regions: List of AWS region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\"].\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n ", + "description": "\n Compare a Bill of Materials' total monthly cost across multiple regions.\n\n v1 scope: PricingSpec-dict items are AWS-only. Each item is an open PricingSpec dict, same\n shape as estimate_bom's items (provider, domain, resource_type/region/etc, plus\n quantity/hours_per_month/size_gb/description) — or a raw-SKU dict (sku, region, plus\n optional service/operation/product_family) for a CUR usage-type/SKU string (provider=\"aws\"\n or omitted) or a GCP Cloud Billing Catalog skuId string (provider=\"gcp\"; operation/\n product_family are ignored for GCP). The region field on each item is overridden per\n comparison — pass any region in the item dicts. A region's region_name is only populated\n from the region-code display maps when every resolvable item in the call shares one\n provider; a mixed-provider call (e.g. an AWS item and a GCP item together) falls back to\n the bare region code instead of guessing whose naming applies.\n Weighting and a providers filter are not supported yet. Unsupported items\n (a PricingSpec-dict item naming a non-AWS provider, or a raw-SKU item naming a provider\n other than aws/gcp) are reported once under \"not_supported\" rather than guessed or dropped\n silently; full GCP/Azure PricingSpec-dict support is tracked separately.\n\n Returns regions[] sorted cheapest-first, each with total_monthly, the\n resolved line_items, and any per-item errors. Optionally shows delta vs\n a baseline region.\n\n Args:\n items: List of PricingSpec dicts (same shape as estimate_bom, AWS-only) — or\n raw-SKU dicts (sku, region, plus optional service/operation/\n product_family), provider \"aws\" (default) or \"gcp\". See estimate_bom for full\n item format.\n regions: List of region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\"].\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n ", "inputSchema": { "properties": { + "baseline_region": { + "default": "", + "title": "Baseline Region", + "type": "string" + }, "items": { + "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, provider aws or gcp) — see tool description.", "items": { "additionalProperties": true, "type": "object" }, "title": "Items", - "type": "array", - "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, AWS-only) \u2014 see tool description." + "type": "array" }, "regions": { "items": { @@ -733,11 +738,6 @@ }, "title": "Regions", "type": "array" - }, - "baseline_region": { - "default": "", - "title": "Baseline Region", - "type": "string" } }, "required": [ From 020b0629f73075d9cac49a2ab5f451f62b960381 Mon Sep 17 00:00:00 2001 From: x7even <38901965+x7even@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:59:05 +0000 Subject: [PATCH 3/9] fix(server): declare tiered/all_tier_rates in SKU-lookup output schemas get_price_by_sku and get_prices_by_sku already emit tiered/all_tier_rates for GCP usage-volume-tiered SKUs and document it in their descriptions, but the OutputSchema for both tools never declared the fields. Found during merge-conflict review while reconciling PR #96 (raw-SKU BoM line items) with PR #97 (GCP SKU lookup parity). --- opencloudcosts-go/internal/server/server.go | 62 +++++++++++++++++++ .../schemas/tools-output-snapshot.json | 62 +++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/opencloudcosts-go/internal/server/server.go b/opencloudcosts-go/internal/server/server.go index e555b66..b691e91 100644 --- a/opencloudcosts-go/internal/server/server.go +++ b/opencloudcosts-go/internal/server/server.go @@ -1351,6 +1351,37 @@ const ( }, "delta_pct": { "type": "string" + }, + "tiered": { + "type": "boolean" + }, + "all_tier_rates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "price_per_unit": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "unit": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "display": { + "type": "string" + } + } + }, + "tier_start_usage": { + "type": "number" + } + } + } } } } @@ -1633,6 +1664,37 @@ const ( }, "delta_pct": { "type": "string" + }, + "tiered": { + "type": "boolean" + }, + "all_tier_rates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "price_per_unit": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "unit": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "display": { + "type": "string" + } + } + }, + "tier_start_usage": { + "type": "number" + } + } + } } } } diff --git a/opencloudcosts-go/schemas/tools-output-snapshot.json b/opencloudcosts-go/schemas/tools-output-snapshot.json index 36f6877..4e90746 100644 --- a/opencloudcosts-go/schemas/tools-output-snapshot.json +++ b/opencloudcosts-go/schemas/tools-output-snapshot.json @@ -689,6 +689,37 @@ }, "delta_pct": { "type": "string" + }, + "tiered": { + "type": "boolean" + }, + "all_tier_rates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "price_per_unit": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "unit": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "display": { + "type": "string" + } + } + }, + "tier_start_usage": { + "type": "number" + } + } + } } } } @@ -974,6 +1005,37 @@ }, "delta_pct": { "type": "string" + }, + "tiered": { + "type": "boolean" + }, + "all_tier_rates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "price_per_unit": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "unit": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "display": { + "type": "string" + } + } + }, + "tier_start_usage": { + "type": "number" + } + } + } } } } From 2e9f94c7270f570a7ddf76eb347aed67c169a3bb Mon Sep 17 00:00:00 2001 From: x7even <38901965+x7even@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:12:16 +0000 Subject: [PATCH 4/9] docs(sku-lookup): fix stale AWS-only doc comments after GCP generalization sku_lookup.go, bom.go, and aws_sku_lookup.go still described raw-SKU lookup as an AWS-only concept in doc comments and one user-facing error message, left over from before RC3-015 generalized it to also support provider="gcp". Comment/string-only changes, no logic touched. --- .../internal/providers/aws/aws_sku_lookup.go | 20 +++++--- opencloudcosts-go/internal/tools/bom.go | 12 +++-- .../internal/tools/sku_lookup.go | 50 +++++++++++-------- 3 files changed, 48 insertions(+), 34 deletions(-) diff --git a/opencloudcosts-go/internal/providers/aws/aws_sku_lookup.go b/opencloudcosts-go/internal/providers/aws/aws_sku_lookup.go index c4a97f4..3fb8d15 100644 --- a/opencloudcosts-go/internal/providers/aws/aws_sku_lookup.go +++ b/opencloudcosts-go/internal/providers/aws/aws_sku_lookup.go @@ -488,11 +488,16 @@ type SKULookupResult = skulookup.SKULookupResult // semaphore of 10, matching the pattern used by compare_prices in // tools/lookup.go). // -// providerName is validated against "aws" as defense-in-depth: this whole -// feature is AWS-only (raw usage-type strings are an AWS CUR concept with no -// GCP/Azure equivalent), and the caller — the tool-handler layer added in a -// later phase — is expected to reject non-AWS providers before ever reaching -// this AWS-package method, but this function does not assume that happened. +// providerName is validated against "aws" as defense-in-depth: this function +// only knows how to parse AWS's usage-type/SKU string shape (GCP's raw-SKU +// lookup is a separate implementation, internal/providers/gcp/ +// gcp_sku_lookup.go, behind the same skulookup.SKULookupProvider interface — +// get_price_by_sku itself supports both). The tool-handler layer +// (internal/tools, see resolveSKULookupProviderFromMap) already resolves +// providerName to the correct concrete provider before ever reaching this +// AWS-package method — LookupSKUAcrossRegionsGeneric below always calls this +// with providerName hardcoded to "aws" — but this function does not assume +// that happened. // // serviceHint, if non-empty, is tried first for every region. If the // usage-type pattern also allows inferring a servicecode (see @@ -527,8 +532,9 @@ func (p *Provider) LookupSKUAcrossRegions( return nil, &SKULookupError{ Code: SKUErrUnsupportedProvider, Message: fmt.Sprintf( - "get_price_by_sku only supports provider=\"aws\" (got %q) — raw AWS usage-type/SKU "+ - "strings are an AWS Cost & Usage Report concept with no GCP/Azure equivalent", + "this raw-SKU lookup path only supports provider=\"aws\" (got %q) — raw AWS "+ + "usage-type/SKU strings are an AWS Cost & Usage Report concept; GCP raw-SKU "+ + "lookup is handled by a separate implementation", providerName, ), } diff --git a/opencloudcosts-go/internal/tools/bom.go b/opencloudcosts-go/internal/tools/bom.go index 60c5ec9..ee6c363 100644 --- a/opencloudcosts-go/internal/tools/bom.go +++ b/opencloudcosts-go/internal/tools/bom.go @@ -273,7 +273,8 @@ func (li bomLineItem) toMap() map[string]any { // whether one was present (a whitespace-only value does not count). Shared // by processBOMItems and HandleCompareBOMRegions's partition loop // (compare_bom_regions.go) so both treat "is this a raw-SKU item" — and the -// exact string handed to the AWS SKU resolver — identically. +// exact string handed to the resolved (AWS or GCP) SKU lookup provider — +// identically. func rawBOMSKU(item map[string]any) (string, bool) { sku, _ := item["sku"].(string) sku = strings.TrimSpace(sku) @@ -363,10 +364,11 @@ func processBOMItems( } description, _ := item["description"].(string) - // Raw-SKU items (issue #31, RC3-004) bypass the PricingSpec path - // entirely — they carry a CUR-style usage-type/SKU string instead of - // a domain/resource_type spec, so resolve them via the same AWS SKU - // lookup get_price_by_sku uses. + // Raw-SKU items (issue #31, RC3-004; GCP parity RC3-015) bypass the + // PricingSpec path entirely — they carry a raw provider-native + // SKU/usage-type string instead of a domain/resource_type spec, so + // resolve them via the same provider-agnostic SKU lookup + // get_price_by_sku uses. if sku, ok := rawBOMSKU(item); ok { li, errMsg := resolveBOMSKUItem(ctx, provs, label, item, sku, quantity, hoursPerMonth, sizeGB, description) if errMsg != "" { diff --git a/opencloudcosts-go/internal/tools/sku_lookup.go b/opencloudcosts-go/internal/tools/sku_lookup.go index e06ee13..7256385 100644 --- a/opencloudcosts-go/internal/tools/sku_lookup.go +++ b/opencloudcosts-go/internal/tools/sku_lookup.go @@ -1,13 +1,17 @@ -// sku_lookup.go implements the get_price_by_sku tool: given a raw AWS -// usage-type/SKU string exactly as it appears in a Cost & Usage Report (CUR) -// export (e.g. "CAN1-BoxUsage:r5a.8xlarge"), resolve its price across a list -// of target regions. +// sku_lookup.go implements the get_price_by_sku tool: given a raw +// provider-native SKU/usage-type string exactly as it appears in a billing +// export (e.g. AWS CUR's "CAN1-BoxUsage:r5a.8xlarge", or a GCP Cloud Billing +// Catalog skuId), resolve its price across a list of target regions. Both AWS +// and GCP are supported (see resolveSKULookupProviderFromMap below); other +// providers (e.g. Azure) are rejected with a structured "unsupported_provider" +// error. // // This file is deliberately kept separate from lookup.go: lookup.go only // imports the provider-agnostic internal/providers package, while this file -// must import the concrete internal/providers/aws package to type-assert the -// AWS-specific core logic (LookupSKUAcrossRegions in aws_sku_lookup.go). -// Isolating that import here keeps lookup.go provider-agnostic. +// must import the concrete internal/providers/aws and internal/providers/gcp +// packages to type-switch each one to the provider-agnostic +// skulookup.SKULookupProvider interface (see resolveSKULookupProviderFromMap). +// Isolating those imports here keeps lookup.go provider-agnostic. package tools import ( @@ -28,7 +32,7 @@ import ( ) // -------------------------------------------------------------------------- -// GetPriceBySKU — raw AWS usage-type/SKU lookup +// GetPriceBySKU — raw provider-native SKU/usage-type lookup (AWS, GCP) // -------------------------------------------------------------------------- // GetPriceBySKUInput is the typed input for the get_price_by_sku tool. @@ -83,12 +87,13 @@ func buildSKUAlternateList(prices []models.NormalizedPrice) []map[string]any { } // HandleGetPriceBySKU implements the get_price_by_sku tool. It resolves a raw -// AWS usage-type/SKU string (as it appears verbatim in a CUR export) against -// each requested region's pricing catalog, and shapes the response to mirror -// compare_prices: a cheapest-first sorted list of matched regions, an -// explicit list of regions where the SKU has no catalog mapping (checked, not -// found — distinct from a fetch failure), and an optional baseline-region -// delta. +// provider-native SKU/usage-type string (an AWS usage-type string as it +// appears verbatim in a CUR export, or a GCP Cloud Billing Catalog skuId) +// against each requested region's pricing catalog, and shapes the response +// to mirror compare_prices: a cheapest-first sorted list of matched regions, +// an explicit list of regions where the SKU has no catalog mapping (checked, +// not found — distinct from a fetch failure), and an optional +// baseline-region delta. func (h *Handler) HandleGetPriceBySKU( ctx context.Context, _ *mcp.CallToolRequest, @@ -514,13 +519,14 @@ type GetPricesBySKUInput struct { } // HandleGetPricesBySKU implements the get_prices_by_sku tool: a batch form -// of get_price_by_sku for resolving many raw AWS usage-type/SKU strings -// (e.g. every distinct line item in a CUR export) against the same set of -// target regions in one call. It reuses resolveSKUPriceEntry — the exact -// same per-SKU resolution and response-shaping logic get_price_by_sku uses -// — for each sku, fanned out concurrently (bounded by skuBatchFanoutLimit) -// so repeated (service, region) catalog fetches across SKUs benefit from the -// process-lifetime skuCatalogCache memoization. +// of get_price_by_sku for resolving many raw provider-native SKU/usage-type +// strings (e.g. every distinct line item in a CUR export, or a batch of GCP +// skuIds) against the same set of target regions in one call. It reuses +// resolveSKUPriceEntry — the exact same per-SKU resolution and +// response-shaping logic get_price_by_sku uses — for each sku, fanned out +// concurrently (bounded by skuBatchFanoutLimit) so repeated (service, region) +// catalog fetches across SKUs benefit from the process-lifetime +// skuCatalogCache memoization. // // Each entry in "results" has exactly the shape a standalone get_price_by_sku // call for that sku would return (including its own ambiguous_in/ @@ -554,7 +560,7 @@ func (h *Handler) HandleGetPricesBySKU( if len(in.SKUs) == 0 { return errResult(map[string]any{ "error": "skus_required", - "message": "skus must contain at least one raw AWS usage-type/SKU string", + "message": "skus must contain at least one raw SKU/usage-type string", }), nil, nil } if len(in.SKUs) > maxSKUsPerBatch { From 8f44ff3b880c0d04c194bbfda1f8ba05b42880db Mon Sep 17 00:00:00 2001 From: x7even <38901965+x7even@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:46:52 +0000 Subject: [PATCH 5/9] feat(azure): add raw-SKU lookup parity with AWS/GCP (RC3-015 Azure) Implements Azure as a third provider behind the shared skulookup.SKULookupProvider interface: fetch once by meterId (no per-region calls), bucket by ArmRegionName, disambiguate by IsPrimaryMeterRegion then type/spot-meterName hints, with a collision-safe tier resolver that reports Ambiguous rather than guessing when Reservation-type rows share identical (meterId, region, type, tierMinimumUnits) but price differently - confirmed against live API data where 86/115 sampled Reservation groups collided this way. Wires provider="azure" through get_price_by_sku, get_prices_by_sku, estimate_bom, and compare_bom_regions raw-SKU items. --- .../internal/providers/azure/azure.go | 21 +- .../providers/azure/azure_sku_lookup.go | 787 ++++++++++++++++++ .../providers/azure/azure_sku_lookup_test.go | 608 ++++++++++++++ opencloudcosts-go/internal/server/server.go | 8 +- opencloudcosts-go/internal/tools/bom.go | 4 +- opencloudcosts-go/internal/tools/bom_test.go | 148 ++++ .../internal/tools/compare_bom_regions.go | 42 +- .../tools/compare_bom_regions_test.go | 85 +- .../internal/tools/lookup_test.go | 86 ++ .../internal/tools/sku_lookup.go | 49 +- .../internal/tools/sku_lookup_test.go | 73 +- opencloudcosts-go/schemas/tools-snapshot.json | 8 +- 12 files changed, 1837 insertions(+), 82 deletions(-) create mode 100644 opencloudcosts-go/internal/providers/azure/azure_sku_lookup.go create mode 100644 opencloudcosts-go/internal/providers/azure/azure_sku_lookup_test.go diff --git a/opencloudcosts-go/internal/providers/azure/azure.go b/opencloudcosts-go/internal/providers/azure/azure.go index 1eccc01..a60220b 100644 --- a/opencloudcosts-go/internal/providers/azure/azure.go +++ b/opencloudcosts-go/internal/providers/azure/azure.go @@ -410,6 +410,14 @@ type azureRetailItem struct { UnitOfMeasure string `json:"unitOfMeasure"` TierMinimumUnits float64 `json:"tierMinimumUnits"` Type string `json:"type"` + // IsPrimaryMeterRegion distinguishes the canonical cross-region row for a + // given meterId from duplicate rows the live Azure Retail Prices API + // serves for the same meter in more than one region entry (required for + // raw-meterId lookup disambiguation — see azure_sku_lookup.go). Not used + // by any pre-existing per-domain fetch in this file (those all filter by + // armRegionName server-side already, so the duplicate-row shape never + // surfaces), but it is a real field the live API returns. + IsPrimaryMeterRegion bool `json:"isPrimaryMeterRegion"` } // azureRetailResponse is the top-level API response. @@ -537,6 +545,15 @@ func (p *Provider) SupportedTerms(domain models.PricingDomain, service string) [ // HTTP helpers // -------------------------------------------------------------------------- +// azureMaxPageSize is the page size ($top) ceiling for a single Azure Retail +// Prices API request. The live API actually serves up to 1000 rows per page +// (verified live) — this previously hard-capped at 100 regardless of the +// maxResults argument, which cost every caller extra round trips for no +// reason, and is far too small for the new SKU-lookup call site +// (azure_sku_lookup.go), whose single meterId fetch must see every +// region/type/tier row for that meter in as few pages as possible. +const azureMaxPageSize = 1000 + // fetchPrices calls the Azure Retail Prices API with the given filters and // follows pagination until maxResults are collected. func (p *Provider) fetchPrices(ctx context.Context, filters map[string]string, maxResults int) ([]azureRetailItem, error) { @@ -547,8 +564,8 @@ func (p *Provider) fetchPrices(ctx context.Context, filters map[string]string, m filterStr := strings.Join(parts, " and ") top := maxResults - if top > 100 { - top = 100 + if top > azureMaxPageSize { + top = azureMaxPageSize } rawURL := fmt.Sprintf("%s?api-version=%s&$filter=%s&$top=%d", p.baseURL, apiVersion, url.QueryEscape(filterStr), top) diff --git a/opencloudcosts-go/internal/providers/azure/azure_sku_lookup.go b/opencloudcosts-go/internal/providers/azure/azure_sku_lookup.go new file mode 100644 index 0000000..1040dc3 --- /dev/null +++ b/opencloudcosts-go/internal/providers/azure/azure_sku_lookup.go @@ -0,0 +1,787 @@ +// azure_sku_lookup.go implements get_price_by_sku's Azure counterpart to +// AWS's raw usage-type/SKU lookup (internal/providers/aws/aws_sku_lookup.go) +// and GCP's raw skuId lookup (internal/providers/gcp/gcp_sku_lookup.go): +// given a raw Azure Retail Prices API "meterId" string (a GUID-shaped token, +// e.g. "0019e0b6-728e-5eae-b900-5b02fa9ba3c9"), find its price in a list of +// target regions. +// +// serviceHint IS ACCEPTED BUT NOT USED (interface conformance only): +// Unlike AWS (which needs a servicecode to pick which offer-file catalog to +// fetch) and GCP (which needs a service ID to pick which of 13 onboarded +// catalogs to scan), a meterId is already a sufficient server-side filter on +// its own — the Azure Retail Prices API's $filter=meterId eq '...' returns +// every row for that meter across every region/type/tier in one shot, +// regardless of which service billed it. So serviceHint is echoed back on +// the result (ServiceHint) for API-shape parity with the other two +// providers, but it never narrows the fetch or the match. This mirrors how +// AWS's now-permanently-dead providerName validation branch is documented as +// intentionally-unused-but-present in aws_sku_lookup.go, and how GCP leaves +// skulookup.SKUHint entirely unused (see gcp_sku_lookup.go's doc comment). +// +// ONE FETCH COVERS EVERY REGION: +// A single fetchPrices(ctx, {"meterId": sku}, ...) call — with NO +// armRegionName filter — returns every region's row for that meterId at +// once (confirmed against the live API). This file never fetches per +// requested region; it fetches once, buckets the raw rows by ArmRegionName, +// and then runs the disambiguation algorithm below independently against +// each requested region's bucket. +// +// DISAMBIGUATION ALGORITHM (per requested region, in this exact order): +// 1. Filter rows to this region (done by the bucketing step, not repeated +// per call — see byRegion in getOrFetchAzureSKUCatalog). +// 2. If the bucket mixes IsPrimaryMeterRegion true/false, keep only the +// true row(s) — this axis is independent of, and resolved strictly +// before, the type-based narrowing in step 5. +// 3. Zero rows remaining (after step 1 or step 2) => NoMapping for this +// region. +// 4. Exactly one row remaining => that's the match. +// 5. More than one row remaining => apply hint.ProductFamilyHint. Azure +// gives this field Azure-specific meaning (NOT the same meaning AWS's +// productFamily hint carries, which matches AWS's top-level +// "productFamily" attribute): +// - hint == "spot" (case-insensitive): filter to rows whose MeterName +// contains "Spot" (case-insensitive substring) — Azure has no +// type=="Spot" value; Spot pricing is identified purely by a +// meterName substring. +// - any other non-empty hint: filter to rows where +// Type == hint (case-insensitive equality). +// - no hint supplied: default-filter to rows where Type == +// "Consumption" (the common "on-demand, not Reservation/DevTest" +// case). +// Fails closed (this repo's established convention — see +// resolveSKUCandidates / T41 in aws_sku_lookup.go and +// docs/plans/T41-sku-lookup.md): a hint matching zero rows reports +// Ambiguous with HintStatusNoMatch and keeps the ORIGINAL (post-step-2) +// candidate set in Prices, never silently falling through. +// 6. If step 5 leaves exactly one row: that's the match (HintStatusResolved +// when an explicit hint was supplied, HintStatusNoHint for the default +// path). If an EXPLICIT hint narrowed the set but still leaves more than +// one row: HintStatusAmbiguous, report Ambiguous with every remaining +// row. No tiebreak is attempted here — see step 7 for why that would be +// unsafe, and note this also makes the explicit-hint path inherently +// safe against the Reservation-tier collision described there, since it +// never tries to pick a "canonical" row out of a multi-row hint match. +// 7. ONLY for the DEFAULT (no-hint) path, when step 5 still leaves more +// than one row: this is the "graduated tiered pricing" case (multiple +// Consumption rows for the same product at different usage thresholds). +// Do NOT assume every such multi-row set is safely resolved by picking +// the lowest TierMinimumUnits — CONFIRMED FROM LIVE DATA, rows can share +// an identical (meterId, region, type, isPrimaryMeterRegion, +// tierMinimumUnits==0.0) tuple while being entirely different products +// with wildly different RetailPrice (one sampled Reservation collision +// spanned $5,048 to $118,201, ~23x). "Lowest TierMinimumUnits" does not +// disambiguate that, since every colliding row ties on that field. So: +// group by (SkuName, ProductName); resolve to one canonical row plus its +// sibling tiers ONLY if exactly one group remains AND that group's +// TierMinimumUnits values are all distinct AND its RetailPrice sequence +// is monotonic against tier threshold — see resolveAzureTierGroup. +// Otherwise: Ambiguous (HintStatusAmbiguous), never guess a "cheapest" +// tiebreak (see docs/plans/T41-sku-lookup.md's documented incident of +// exactly that failure mode picking the wrong product at ~half price). +// +// CONVERSION: only the winning row(s) per region are ever converted to +// models.NormalizedPrice (via the existing itemToPrice, azure.go), since +// NormalizedPrice drops Type/TierMinimumUnits/IsPrimaryMeterRegion that the +// algorithm above needs while candidates are still being narrowed. When a +// region is Ambiguous, every row that survived to the Ambiguous report (not +// the full unfiltered per-meterId fetch) is converted so the caller can +// inspect them. +// +// UsageTypePrefix/UsageTypeSuffix are AWS-only concepts per +// skulookup.SKULookupResult's doc comment; left "" here, mirroring GCP +// (gcp_sku_lookup.go also leaves them unset). +package azure + +import ( + "context" + "fmt" + "log/slog" + "sort" + "strings" + "sync" + "time" + + "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/models" + "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/skulookup" +) + +// -------------------------------------------------------------------------- +// Input bounds +// -------------------------------------------------------------------------- + +// azureSKUMaxLength bounds the raw meterId string. Mirrors +// gcp.gcpSKUMaxLength / aws.maxSKULength's rationale: real meterId values are +// short GUIDs, far under this cap; the cap exists only to reject +// pathological/abusive input before it's echoed into error messages or used +// to build the outbound $filter, not to constrain any real meterId shape. +const azureSKUMaxLength = 1024 + +// azureSKUMaxLookupRegions bounds the regions list, mirroring +// gcp.gcpSKUMaxLookupRegions's rationale: the fetch itself is not +// region-scoped (one call covers every region), but an unbounded regions +// list still means unbounded per-region work building the response. +const azureSKUMaxLookupRegions = 30 + +// azureSKUMaxHintLength bounds hint.ProductFamilyHint, mirroring +// aws.maxHintLength's rationale — real Azure "type"/meterName values used +// here are short; this is generous headroom, not a real-world constraint. +const azureSKUMaxHintLength = 256 + +// azureSKULookupMaxResults is the maxResults ceiling passed to fetchPrices +// for this file's single-meterId fetch specifically. It must be sized +// generously: a single meterId's row count across every Azure +// region/type/tier can plausibly reach into the hundreds, and this fetch has +// exactly one chance to see all of them (there is no per-region retry — +// getOrFetchAzureSKUCatalog fetches and buckets once, then every requested +// region is resolved from that single bucketed result). Deliberately much +// larger than any other call site in this package uses. +const azureSKULookupMaxResults = 5000 + +// -------------------------------------------------------------------------- +// Process-lifetime catalog memoization (mirrors aws.skuCatalogCache) +// -------------------------------------------------------------------------- +// +// WHY THIS CACHE EXISTS: a single meterId fetch already covers every region +// in one HTTP round trip (unlike AWS, which must re-fetch an entire +// per-(service,region) offer file for every candidate). But a caller +// reconciling a CUR/cost-export line-by-line can still repeat the same +// meterId lookup many times (once per invoice line referencing it, or once +// per requested-region re-run with a different hint), and concurrent lookups +// for the same meterId should collapse into one fetch rather than a +// stampede. This is a small, Azure-SKU-lookup-scoped, in-memory, +// coalescing, TTL-bounded, size-capped cache modeled directly on AWS's +// skuCatalogCache (aws_sku_lookup.go) — see that type's doc comment for the +// full reasoning (which applies here unchanged): it is deliberately NOT a +// bare package-level sync.Once (that would pin a transient network failure +// for the rest of the process's life) and deliberately NOT layered on +// Provider.cache (cache.CacheManager does a whole-file rewrite per Set and +// is designed for many small already-priced entries, not raw per-meterId row +// data that needs to survive a follow-up call with a different hint without +// re-fetching). +var azureSKUCatalogCache = struct { + mu sync.Mutex + entries map[string]*azureSKUCatalogEntry +}{entries: make(map[string]*azureSKUCatalogEntry)} + +// defaultAzureSKUCatalogEntryTTL bounds how long a fetched meterId's row set +// is reused before being treated as stale and re-fetched. Mirrors +// aws.defaultSKUCatalogEntryTTL's rationale. +const defaultAzureSKUCatalogEntryTTL = 24 * time.Hour + +// maxAzureSKUCatalogCacheEntries hard-caps the number of distinct meterId +// entries held at once, combined with the TTL above bounding worst-case +// memory footprint. Mirrors aws.maxSKUCatalogCacheEntries. +const maxAzureSKUCatalogCacheEntries = 128 + +// azureSKUCatalogEntry holds the memoized raw-row-bucketed-by-region result +// for one meterId. Storing raw azureRetailItem rows (not +// []models.NormalizedPrice) is deliberate: a cache hit must still be able to +// re-run the disambiguation algorithm with a different hint on a follow-up +// call without re-fetching, and NormalizedPrice has already dropped fields +// (Type, TierMinimumUnits, IsPrimaryMeterRegion) the algorithm needs. +type azureSKUCatalogEntry struct { + once sync.Once + byRegion map[string][]azureRetailItem + err error + + // fetchedAt mirrors aws.skuCatalogEntry.fetchedAt: set to time.Now() + // (under azureSKUCatalogCache.mu, by the sole goroutine running + // once.Do's body) once the fetch completes; stays the zero Time while a + // fetch is in flight, so getAzureSKUCatalogEntry/evictOldestAzureSKUEntryLocked + // never treat/evict an in-flight entry as stale/evictable. + fetchedAt time.Time +} + +// getAzureSKUCatalogEntry returns the (possibly new) cache slot for key, +// creating it under the map mutex if absent or stale. Mirrors +// aws.getSKUCatalogEntry. +func getAzureSKUCatalogEntry(key string, ttl time.Duration) *azureSKUCatalogEntry { + azureSKUCatalogCache.mu.Lock() + defer azureSKUCatalogCache.mu.Unlock() + + if e, ok := azureSKUCatalogCache.entries[key]; ok { + if e.fetchedAt.IsZero() || time.Since(e.fetchedAt) <= ttl { + return e + } + delete(azureSKUCatalogCache.entries, key) + } + + if len(azureSKUCatalogCache.entries) >= maxAzureSKUCatalogCacheEntries { + evictOldestAzureSKUEntryLocked() + } + + e := &azureSKUCatalogEntry{} + azureSKUCatalogCache.entries[key] = e + return e +} + +// evictOldestAzureSKUEntryLocked removes the completed entry with the oldest +// fetchedAt. Mirrors aws.evictOldestLocked. Callers must hold +// azureSKUCatalogCache.mu. +func evictOldestAzureSKUEntryLocked() { + var oldestKey string + var oldestAt time.Time + for k, e := range azureSKUCatalogCache.entries { + if e.fetchedAt.IsZero() { + continue + } + if oldestKey == "" || e.fetchedAt.Before(oldestAt) { + oldestKey, oldestAt = k, e.fetchedAt + } + } + if oldestKey != "" { + delete(azureSKUCatalogCache.entries, oldestKey) + } +} + +// azureSKUCatalogCacheTTL returns p's configured cache TTL, falling back to +// defaultAzureSKUCatalogEntryTTL when p is nil or its cacheTTL is unset. +// Mirrors aws.skuCatalogCacheTTL. +func azureSKUCatalogCacheTTL(p *Provider) time.Duration { + if p != nil && p.cacheTTL > 0 { + return p.cacheTTL + } + return defaultAzureSKUCatalogEntryTTL +} + +// getOrFetchAzureSKUCatalog returns the memoized region->rows index for +// meterID, fetching it via fetchAzureSKUCatalog on first use or once the +// previous fetch has aged past its TTL. A failed fetch is intentionally NOT +// memoized permanently — mirrors aws.getOrFetchSKUCatalog's eviction-on-error +// behavior, letting the next caller retry from scratch instead of being +// stuck with a transient network failure for the rest of the entry's TTL. +func (p *Provider) getOrFetchAzureSKUCatalog(ctx context.Context, meterID string) (map[string][]azureRetailItem, error) { + key := cacheKey("sku_lookup", "", map[string]string{"sku": meterID}) + entry := getAzureSKUCatalogEntry(key, azureSKUCatalogCacheTTL(p)) + entry.once.Do(func() { + entry.byRegion, entry.err = fetchAzureSKUCatalog(ctx, p, meterID) + azureSKUCatalogCache.mu.Lock() + entry.fetchedAt = time.Now() + azureSKUCatalogCache.mu.Unlock() + }) + if entry.err != nil { + azureSKUCatalogCache.mu.Lock() + if azureSKUCatalogCache.entries[key] == entry { + delete(azureSKUCatalogCache.entries, key) + } + azureSKUCatalogCache.mu.Unlock() + } + return entry.byRegion, entry.err +} + +// fetchAzureSKUCatalog fetches every row for meterID (one server-side +// $filter=meterId eq '...' call, no armRegionName filter — this covers +// every region in a single shot) and buckets the raw rows by ArmRegionName. +func fetchAzureSKUCatalog(ctx context.Context, p *Provider, meterID string) (map[string][]azureRetailItem, error) { + items, err := p.fetchPrices(ctx, map[string]string{"meterId": meterID}, azureSKULookupMaxResults) + if err != nil { + return nil, fmt.Errorf("azure sku lookup: fetch meterId %q: %w", meterID, err) + } + if len(items) == azureSKULookupMaxResults { + // This fetch has exactly one chance to see every row for meterID + // (see azureSKULookupMaxResults's doc comment) — hitting the cap + // exactly is indistinguishable, from here, between "the catalog + // happened to have exactly this many matching rows" and "there were + // more rows this lookup never saw," which would silently under-report + // candidates/regions for meterID. Warn so it's at least visible. + slog.Warn("azure sku lookup: fetched row count exactly equals max_results; result may be silently truncated", + "meter_id", meterID, "max_results", azureSKULookupMaxResults) + } + byRegion := make(map[string][]azureRetailItem, len(items)) + for _, item := range items { + byRegion[item.ArmRegionName] = append(byRegion[item.ArmRegionName], item) + } + return byRegion, nil +} + +// -------------------------------------------------------------------------- +// Per-region disambiguation +// -------------------------------------------------------------------------- + +// filterAzurePrimaryMeterRegion implements algorithm step 2: when bucket +// mixes IsPrimaryMeterRegion true/false, keep only the true row(s); when it +// does not mix (all true, or all false — no established "primary" among +// them), leave bucket unchanged. This axis is resolved independently of, and +// strictly before, the type-based narrowing in step 5. +func filterAzurePrimaryMeterRegion(bucket []azureRetailItem) []azureRetailItem { + var primaryTrue, primaryFalse []azureRetailItem + for _, r := range bucket { + if r.IsPrimaryMeterRegion { + primaryTrue = append(primaryTrue, r) + } else { + primaryFalse = append(primaryFalse, r) + } + } + if len(primaryTrue) > 0 && len(primaryFalse) > 0 { + return primaryTrue + } + return bucket +} + +// applyAzureSKUHint implements algorithm step 5's three branches (spot +// substring / explicit type equality / default Consumption). explicitHint +// reports whether a non-empty hint was actually supplied (as opposed to the +// default no-hint path), which the caller needs to pick the right +// HintStatus and to decide whether step 7's tier-collision-aware resolution +// is even eligible to run (default path only — see this file's top-of-file +// doc comment for why the explicit-hint path is inherently safe without it). +func applyAzureSKUHint(rows []azureRetailItem, productFamilyHint string) (filtered []azureRetailItem, explicitHint bool) { + hint := strings.TrimSpace(productFamilyHint) + if hint == "" { + return filterAzureRowsByType(rows, "Consumption"), false + } + if strings.EqualFold(hint, "spot") { + return filterAzureRowsByMeterNameSubstring(rows, "Spot"), true + } + return filterAzureRowsByType(rows, hint), true +} + +func filterAzureRowsByType(rows []azureRetailItem, wantType string) []azureRetailItem { + var out []azureRetailItem + for _, r := range rows { + if strings.EqualFold(r.Type, wantType) { + out = append(out, r) + } + } + return out +} + +func filterAzureRowsByMeterNameSubstring(rows []azureRetailItem, substr string) []azureRetailItem { + var out []azureRetailItem + lowerSubstr := strings.ToLower(substr) + for _, r := range rows { + if strings.Contains(strings.ToLower(r.MeterName), lowerSubstr) { + out = append(out, r) + } + } + return out +} + +// azureSKUProductGroupKey identifies the (SkuName, ProductName) pair that +// must be identical across a row set for it to be genuine usage-volume tiers +// of ONE billable product line — see resolveAzureTierGroup. +type azureSKUProductGroupKey struct{ skuName, productName string } + +// resolveAzureTierGroup implements algorithm step 7: attempts to resolve +// rows (already narrowed to the default Type=="Consumption" path with more +// than one row remaining) to a confirmed tier ladder. Returns ok=false +// whenever any part of that confirmation fails — callers MUST report +// Ambiguous rather than guess, per this file's doc comment's account of the +// live Reservation-tier-collision incident this guards against. +// +// Resolution requires ALL of: +// 1. Exactly one (SkuName, ProductName) group among rows — more than one +// means rows are not the same billable product line at all (this alone +// is what separates the live collision incident's rows, which had +// different SkuName/ProductName). +// 2. Every row in that group has a distinct TierMinimumUnits — a duplicate +// threshold is not a real tier ladder (an independent second guard, +// since the collision incident's rows also happened to share an +// identical tierMinimumUnits==0.0). +// 3. RetailPrice is monotonic (either consistently non-increasing — +// the common volume-discount shape — or consistently non-decreasing) +// against ascending TierMinimumUnits order. A non-monotonic sequence is +// not a clean graduated-tier step function and must not be silently +// resolved. +// +// On success, tiers is every row in the confirmed group, sorted ascending +// by TierMinimumUnits — tiers[0] is the base-tier canonical row. +func resolveAzureTierGroup(rows []azureRetailItem) (tiers []azureRetailItem, ok bool) { + groups := make(map[azureSKUProductGroupKey][]azureRetailItem, len(rows)) + for _, r := range rows { + key := azureSKUProductGroupKey{skuName: r.SkuName, productName: r.ProductName} + groups[key] = append(groups[key], r) + } + if len(groups) != 1 { + return nil, false + } + var group []azureRetailItem + for _, g := range groups { + group = g + } + + seenTiers := make(map[float64]bool, len(group)) + for _, r := range group { + if seenTiers[r.TierMinimumUnits] { + return nil, false + } + seenTiers[r.TierMinimumUnits] = true + } + + sorted := append([]azureRetailItem(nil), group...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].TierMinimumUnits < sorted[j].TierMinimumUnits }) + + if !azureRetailPriceMonotonic(sorted) { + return nil, false + } + return sorted, true +} + +// azureRetailPriceMonotonic reports whether sorted's RetailPrice sequence is +// consistently non-increasing or consistently non-decreasing. +func azureRetailPriceMonotonic(sorted []azureRetailItem) bool { + nonIncreasing, nonDecreasing := true, true + for i := 1; i < len(sorted); i++ { + switch { + case sorted[i].RetailPrice > sorted[i-1].RetailPrice: + nonIncreasing = false + case sorted[i].RetailPrice < sorted[i-1].RetailPrice: + nonDecreasing = false + } + } + return nonIncreasing || nonDecreasing +} + +// azureSKUItemTerm infers a models.PricingTerm for a matched raw +// azureRetailItem. This is necessarily best-effort: azureRetailItem does not +// carry the API's separate "reservationTerm" field (1 Year vs 3 Years) — no +// existing fetch path in this package reads it back from the response (see +// GetComputePrice, which only ever sends reservationTerm as an outbound +// filter) — so a matched Reservation row is reported as Reserved1Yr +// regardless of its actual commitment length. A caller needing the exact +// term should treat this as "some reservation", not literal, and inspect +// Attributes["type"]/Attributes["unitOfMeasure"] for more detail. +func azureSKUItemTerm(item azureRetailItem) models.PricingTerm { + meter := strings.ToLower(item.MeterName) + if strings.Contains(meter, "spot") || strings.Contains(meter, "low priority") { + return models.PricingTermSpot + } + if strings.EqualFold(item.Type, "Reservation") { + return models.PricingTermReserved1Yr + } + return models.PricingTermOnDemand +} + +// azureSKUUnit derives the most appropriate models.PriceUnit for a raw +// Retail Prices API row from its UnitOfMeasure (and, for a couple of +// meterName-only cases, MeterName) fields. This is deliberately best-effort +// and generic: unlike every existing per-domain Azure handler in this +// package (each of which knows in advance exactly which unit its own +// service uses, e.g. azure_functions's hand-picked GB-second/per-request/ +// per-hour switch), a domain-agnostic raw-SKU lookup has no a priori +// knowledge of which unit fits an arbitrary meterId — mirrors +// gcp_sku_lookup.go's equivalent gcpSKUUnit() heuristic. +// +// When nothing matches, this falls back to models.PriceUnitPerUnit rather +// than silently defaulting to per-hour: PriceUnitPerHour flows into +// NormalizedPrice.MonthlyCost()/HourlyCost() as an active *730/÷730 +// multiplier, so a wrong per-hour label doesn't just mislabel the row, it +// actively corrupts any derived monthly/hourly figure. That previously +// happened for every non-VM meter (e.g. a Reservation row's UnitOfMeasure +// is a contract-length label like "1 Year", not an hourly rate — the old +// hardcoded per-hour default overstated its "monthly cost" by ~730x). +// PriceUnitPerUnit is a safe inert fallback: both MonthlyCost and +// HourlyCost return PricePerUnit unchanged for any unlisted unit. +// +// KNOWN LIMITATION: this does not attempt the "leading quantity" packaging +// normalization some Azure UnitOfMeasure strings encode (e.g. "10K", +// "1M", "100/Hour" priced per that many units, not per 1) — see +// azure.go's azure_functions handler for the one place in this package +// that does attempt a (partial, space-separated-only) version of that +// normalization for a known meter shape. Applying it generically here is +// unsafe: a Reservation row's UnitOfMeasure of "3 Years" would be +// misread as "priced per 3 units" and its PricePerUnit wrongly divided by +// 3. Left as a documented gap rather than risking that regression. +func azureSKUUnit(item azureRetailItem) models.PriceUnit { + uom := strings.ToLower(item.UnitOfMeasure) + meter := strings.ToLower(item.MeterName) + + switch { + // GB-second before GB/Month and GB below: "1 GB Second" contains "gb" + // and would otherwise match one of the coarser GB cases first. + case strings.Contains(uom, "gb") && strings.Contains(uom, "second"): + return models.PriceUnitPerGBSecond + case strings.Contains(uom, "gb") && strings.Contains(uom, "month"): + return models.PriceUnitPerGBMonth + case strings.Contains(uom, "gb"): + return models.PriceUnitPerGB + case strings.Contains(uom, "hour"): + return models.PriceUnitPerHour + case strings.Contains(uom, "month"): + return models.PriceUnitPerMonth + case strings.Contains(meter, "operation") || strings.Contains(uom, "operation"): + return models.PriceUnitPerOperation + case strings.Contains(meter, "execution") || strings.Contains(meter, "invocation") || + strings.Contains(meter, "request") || strings.Contains(meter, "transaction"): + return models.PriceUnitPerRequest + case strings.Contains(meter, "query"): + return models.PriceUnitPerQuery + default: + return models.PriceUnitPerUnit + } +} + +// azureSKUItemToPrice converts one winning/candidate raw azureRetailItem row +// to a models.NormalizedPrice, via the existing itemToPrice (azure.go), +// passing the row's own ArmRegionName. Enriches the result with the raw +// fields itemToPrice does not carry (type, tierMinimumUnits, +// isPrimaryMeterRegion) so a caller inspecting an Ambiguous or Tiered result +// still has access to the attributes the disambiguation algorithm itself +// used, even though NormalizedPrice has no first-class fields for them. +func azureSKUItemToPrice(item azureRetailItem) models.NormalizedPrice { + term := azureSKUItemTerm(item) + unit := azureSKUUnit(item) + pp := itemToPrice(item, item.ArmRegionName, term, item.ServiceName) + var np models.NormalizedPrice + if pp != nil { + np = *pp + // itemToPrice hardcodes PriceUnitPerHour (correct for its own VM/ + // disk/etc. call sites, which only ever pass compute-shaped rows) — + // override with the unit actually derived from this row's own + // UnitOfMeasure, since a domain-agnostic raw-SKU lookup can match + // any meter shape (storage, bandwidth, requests, ...), not just + // hourly compute. + np.Unit = unit + } else { + // itemToPrice returns nil for a zero RetailPrice. A zero-priced raw + // meterId row is unusual but not impossible (e.g. some free-tier + // meters) — synthesize a minimal NormalizedPrice rather than + // dropping the row the disambiguation algorithm has already + // selected as this region's match/candidate. + np = models.NormalizedPrice{ + Provider: models.CloudProviderAzure, + Service: item.ServiceName, + SKUID: item.MeterID, + ProductFamily: item.ServiceFamily, + Description: item.SkuName, + Region: item.ArmRegionName, + PricingTerm: term, + PricePerUnit: 0, + Unit: unit, + Currency: "USD", + } + } + attrs := make(map[string]string, len(np.Attributes)+4) + for k, v := range np.Attributes { + attrs[k] = v + } + attrs["type"] = item.Type + attrs["tierMinimumUnits"] = fmt.Sprintf("%g", item.TierMinimumUnits) + attrs["isPrimaryMeterRegion"] = fmt.Sprintf("%t", item.IsPrimaryMeterRegion) + // tier_start_usage is consumed generically (no provider gate — see + // bom.go's resolveBOMSKUItem) by the same graduated-tiered-pricing path + // GCP's raw-SKU lookup already populates it for (gcp_sku_lookup.go). + // Set unconditionally (harmless on non-tiered rows, since bom.go only + // reads it when SKULookupRegionResult.Tiered is true) so that when + // resolveAzureTierGroup succeeds and rr.Tiered is set, every resulting + // tier row already carries the attribute the cost calculation needs — + // without this, a tiered Azure SKU silently prices at $0.00/mo (every + // tier gets skipped by tierStartUsage's ok=false path). + attrs["tier_start_usage"] = fmt.Sprintf("%g", item.TierMinimumUnits) + np.Attributes = attrs + return np +} + +func azureSKUConvertAll(rows []azureRetailItem) []models.NormalizedPrice { + out := make([]models.NormalizedPrice, 0, len(rows)) + for _, r := range rows { + out = append(out, azureSKUItemToPrice(r)) + } + return out +} + +// resolveAzureSKURegion runs the full per-region disambiguation algorithm +// (steps 1-7, see this file's top-of-file doc comment) against bucket (every +// row already filtered to this region by the caller) and hint. +func resolveAzureSKURegion(bucket []azureRetailItem, region string, hint skulookup.SKUHint) skulookup.SKULookupRegionResult { + rr := skulookup.SKULookupRegionResult{Region: region} + + // Step 3 (first occurrence): no rows at all for this region. + if len(bucket) == 0 { + rr.NoMapping = true + return rr + } + + // Step 2. + rows := filterAzurePrimaryMeterRegion(bucket) + + // Step 3 (second occurrence): the primary/non-primary split removed + // every row (should not happen in practice — a mix always keeps at + // least the primary row(s) — but handled defensively). + if len(rows) == 0 { + rr.NoMapping = true + return rr + } + + // ServiceUsed is invariant across every row in this region's bucket (see + // azureSKUServiceUsed's doc comment: a single meterId's rows share one + // ServiceName in every real Azure catalog row observed), so it is safe + // to compute once here from the full (post-step-2) rows set and reuse it + // at every return point below, rather than recomputing it from whatever + // narrower subset (filtered/tiers) happens to be in scope at each + // return. + rr.ServiceUsed = azureSKUServiceUsed(rows) + + // Step 4. Deliberately short-circuits BEFORE hint.ProductFamilyHint is + // even consulted (step 5) — this is a direct reading of this file's own + // top-of-file algorithm doc ("4. Exactly one row remaining => that's + // the match" precedes "5. ... apply hint.ProductFamilyHint"), not an + // oversight. It does diverge from AWS's resolveSKUCandidates, which + // still validates a supplied hint even when only one candidate remains + // (see aws_sku_lookup.go). Left as specified: with only one row in the + // bucket there is no second candidate a hint could disambiguate away + // from, so there is nothing for step 5 to narrow. + if len(rows) == 1 { + rr.Prices = []models.NormalizedPrice{azureSKUItemToPrice(rows[0])} + rr.HintStatus = skulookup.HintStatusNoHint + return rr + } + + // Step 5. + filtered, explicitHint := applyAzureSKUHint(rows, hint.ProductFamilyHint) + + switch { + case len(filtered) == 0: + // Fails closed: original (post-step-2) candidate set, still + // ambiguous, never silently ignoring the hint. HintStatusNoMatch is + // only accurate when a hint was actually SUPPLIED and matched + // nothing (skulookup.HintStatusNoMatch's doc: "a hint was SUPPLIED + // but matched zero rows") — the default no-hint path (which + // defensively also runs through this branch, since + // applyAzureSKUHint's Consumption default can itself filter every + // row away) reports HintStatusNoHint instead, mirroring the + // len(filtered) == 1 branch just below. + rr.Ambiguous = true + if explicitHint { + rr.HintStatus = skulookup.HintStatusNoMatch + } else { + rr.HintStatus = skulookup.HintStatusNoHint + } + rr.Prices = azureSKUConvertAll(rows) + return rr + + case len(filtered) == 1: + rr.Prices = []models.NormalizedPrice{azureSKUItemToPrice(filtered[0])} + if explicitHint { + rr.HintStatus = skulookup.HintStatusResolved + } else { + rr.HintStatus = skulookup.HintStatusNoHint + } + return rr + + default: + // len(filtered) > 1. + if !explicitHint { + // Step 7 — tier-collision-aware resolution, default path only. + // See resolveAzureTierGroup's doc for why an explicit hint never + // attempts this (step 6 sends any explicit-hint multi-row result + // straight to Ambiguous instead). + if tiers, ok := resolveAzureTierGroup(filtered); ok { + rr.Prices = azureSKUConvertAll(tiers) + rr.Tiered = len(tiers) > 1 + rr.HintStatus = skulookup.HintStatusNoHint + return rr + } + } + rr.Ambiguous = true + rr.HintStatus = skulookup.HintStatusAmbiguous + rr.Prices = azureSKUConvertAll(filtered) + return rr + } +} + +// azureSKUServiceUsed returns a representative ServiceName (e.g. "Virtual +// Machines") for a non-empty row set, for SKULookupRegionResult.ServiceUsed. +// A single meterId's rows share one ServiceName in every real Azure catalog +// row observed, so rows[0] is a safe, non-arbitrary representative even for +// an Ambiguous multi-row result. +func azureSKUServiceUsed(rows []azureRetailItem) string { + if len(rows) == 0 { + return "" + } + return rows[0].ServiceName +} + +// uniformAzureSKURegionResults builds one skulookup.SKULookupRegionResult per +// region, each a copy of tmpl with only Region varying. Mirrors +// gcp.uniformRegionResults. +func uniformAzureSKURegionResults(regions []string, tmpl skulookup.SKULookupRegionResult) []skulookup.SKULookupRegionResult { + out := make([]skulookup.SKULookupRegionResult, len(regions)) + for i, region := range regions { + rr := tmpl + rr.Region = region + out[i] = rr + } + return out +} + +// -------------------------------------------------------------------------- +// LookupSKUAcrossRegionsGeneric — skulookup.SKULookupProvider conformance +// -------------------------------------------------------------------------- + +// LookupSKUAcrossRegionsGeneric resolves the price of a raw Azure Retail +// Prices API meterId string in each of the given regions. serviceHint is +// accepted for skulookup.SKULookupProvider interface conformance but is NOT +// used to filter — see this file's top-of-file doc comment for why a +// meterId alone is already a sufficient server-side filter. hint.OperationHint +// is likewise accepted but unused: Azure has no concept analogous to AWS's +// "operation" attribute for this lookup. Only hint.ProductFamilyHint is used +// (Azure-specific meaning — see the algorithm doc above). +func (p *Provider) LookupSKUAcrossRegionsGeneric( + ctx context.Context, sku string, regions []string, serviceHint string, hint skulookup.SKUHint, +) (*skulookup.SKULookupResult, error) { + _ = hint.OperationHint + + if sku == "" { + return nil, &skulookup.SKULookupError{Code: skulookup.SKUErrSKURequired, Message: "sku must not be empty"} + } + if len(sku) > azureSKUMaxLength { + return nil, &skulookup.SKULookupError{ + Code: skulookup.SKUErrSKUTooLong, + Message: fmt.Sprintf( + "sku must be at most %d characters (got %d) — real Azure meterId values are GUID-shaped, far shorter", + azureSKUMaxLength, len(sku)), + } + } + if len(regions) == 0 { + return nil, &skulookup.SKULookupError{ + Code: skulookup.SKUErrRegionsRequired, + Message: "regions must contain at least one Azure ARM region name", + } + } + if len(regions) > azureSKUMaxLookupRegions { + return nil, &skulookup.SKULookupError{ + Code: skulookup.SKUErrTooManyRegions, + Message: fmt.Sprintf( + "regions must contain at most %d entries (got %d)", azureSKUMaxLookupRegions, len(regions)), + } + } + if len(hint.ProductFamilyHint) > azureSKUMaxHintLength { + return nil, &skulookup.SKULookupError{ + Code: skulookup.SKUErrHintTooLong, + Message: fmt.Sprintf( + "product_family must be at most %d characters (got %d)", + azureSKUMaxHintLength, len(hint.ProductFamilyHint)), + } + } + + result := &skulookup.SKULookupResult{ + SKU: sku, + ServiceHint: serviceHint, + } + if serviceHint != "" { + // Azure has no candidate-service resolution step for this lookup + // (see top-of-file doc) — "explicit" here only means "the caller + // supplied one", not "it was used to pick anything". + result.ServiceSource = "explicit" + result.Warnings = append(result.Warnings, + "service is not used to filter Azure raw-SKU lookup — meterId alone is a sufficient "+ + "server-side filter; the supplied service hint is echoed back but has no effect on the match") + } else { + result.ServiceSource = "not_applicable" + } + + byRegion, err := p.getOrFetchAzureSKUCatalog(ctx, sku) + if err != nil { + msg := fmt.Sprintf("could not fetch Azure Retail Prices catalog for meterId %q: %v", sku, err) + result.Regions = uniformAzureSKURegionResults(regions, skulookup.SKULookupRegionResult{Error: msg}) + return result, nil + } + + regionResults := make([]skulookup.SKULookupRegionResult, len(regions)) + for i, region := range regions { + regionResults[i] = resolveAzureSKURegion(byRegion[region], region, hint) + } + result.Regions = regionResults + return result, nil +} diff --git a/opencloudcosts-go/internal/providers/azure/azure_sku_lookup_test.go b/opencloudcosts-go/internal/providers/azure/azure_sku_lookup_test.go new file mode 100644 index 0000000..0a0109d --- /dev/null +++ b/opencloudcosts-go/internal/providers/azure/azure_sku_lookup_test.go @@ -0,0 +1,608 @@ +// azure_sku_lookup_test.go tests LookupSKUAcrossRegionsGeneric (RC3-015 +// Azure), the Azure raw-meterId counterpart to AWS's/GCP's get_price_by_sku +// lookup. Coverage focuses on the 7-step disambiguation algorithm documented +// at the top of azure_sku_lookup.go: primary/non-primary meter-region +// dedup, the spot/type/default hint branches, fails-closed no-match +// handling, and — critically — the tier-collision guard that must report +// Ambiguous instead of silently picking a wrong-priced row when multiple +// rows share every disambiguating field except SkuName/ProductName. +// +// Internal (package azure, not azure_test) so fixtures can be built directly +// as azureRetailItem values instead of via untyped JSON maps — mirrors +// aws_sku_lookup_test.go (package aws) / gcp_sku_lookup_test.go (package +// gcp)'s precedent for this kind of provider-internal algorithm test. +package azure + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/cache" + "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/models" + "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/skulookup" +) + +// -------------------------------------------------------------------------- +// Fixture helpers +// -------------------------------------------------------------------------- + +// azureSKUServer builds a fake Azure Retail Prices API server that always +// serves items as a single-page response (NextPageLink empty), regardless +// of the request's own $filter — every test here drives exactly one meterId +// per server, so no request-side routing is needed. +func azureSKUServer(t *testing.T, items []azureRetailItem) *httptest.Server { + t.Helper() + body, err := json.Marshal(azureRetailResponse{Items: items}) + if err != nil { + t.Fatal(err) + } + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + })) +} + +// newSKULookupTestProvider creates a Provider backed by a temp-dir cache, +// pointing at srv. Every test must use a meterId unique to that test (not +// reused across tests) since azureSKUCatalogCache is a package-level cache +// keyed only by meterId — reusing a meterId across tests sharing the test +// binary's process lifetime would return a stale cached result from a +// different (by-then-closed) server instead of exercising the new one. +func newSKULookupTestProvider(t *testing.T, srv *httptest.Server) *Provider { + t.Helper() + dir := t.TempDir() + cm, err := cache.New(dir) + if err != nil { + t.Fatal(err) + } + p := NewProvider(cm, 24*time.Hour, 7*24*time.Hour) + p.SetBaseURL(srv.URL) + p.SetHTTPClient(srv.Client()) + return p +} + +func consumptionItem(meterID, region, skuName, productName string, tierMin, price float64) azureRetailItem { + return azureRetailItem{ + RetailPrice: price, + SkuName: skuName, + ArmSkuName: "Standard_Test", + ProductName: productName, + MeterName: skuName, + ServiceName: "Virtual Machines", + ServiceFamily: "Compute", + MeterID: meterID, + ArmRegionName: region, + UnitOfMeasure: "1 Hour", + TierMinimumUnits: tierMin, + Type: "Consumption", + IsPrimaryMeterRegion: true, + } +} + +// -------------------------------------------------------------------------- +// (a) zero rows for a region -> NoMapping +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_NoRowsForRegion(t *testing.T) { + items := []azureRetailItem{ + consumptionItem("meter-a", "eastus", "D4s v3", "Virtual Machines DSv3 Series", 0, 0.192), + } + srv := azureSKUServer(t, items) + defer srv.Close() + p := newSKULookupTestProvider(t, srv) + + result, err := p.LookupSKUAcrossRegionsGeneric(context.Background(), "meter-a", []string{"westeurope"}, "", skulookup.SKUHint{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Regions) != 1 { + t.Fatalf("expected 1 region result, got %d", len(result.Regions)) + } + rr := result.Regions[0] + if !rr.NoMapping { + t.Errorf("expected NoMapping=true for region with zero rows, got %+v", rr) + } + if rr.Ambiguous { + t.Errorf("expected Ambiguous=false, got true") + } +} + +// -------------------------------------------------------------------------- +// (b) one row for a region -> unambiguous match +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_SingleRowMatch(t *testing.T) { + items := []azureRetailItem{ + consumptionItem("meter-b", "eastus", "D4s v3", "Virtual Machines DSv3 Series", 0, 0.192), + } + srv := azureSKUServer(t, items) + defer srv.Close() + p := newSKULookupTestProvider(t, srv) + + result, err := p.LookupSKUAcrossRegionsGeneric(context.Background(), "meter-b", []string{"eastus"}, "", skulookup.SKUHint{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + rr := result.Regions[0] + if rr.NoMapping || rr.Ambiguous { + t.Fatalf("expected a clean match, got %+v", rr) + } + if len(rr.Prices) != 1 { + t.Fatalf("expected exactly 1 price, got %d", len(rr.Prices)) + } + if rr.Prices[0].PricePerUnit != 0.192 { + t.Errorf("expected price 0.192, got %v", rr.Prices[0].PricePerUnit) + } + if rr.HintStatus != skulookup.HintStatusNoHint { + t.Errorf("expected HintStatusNoHint, got %q", rr.HintStatus) + } +} + +// -------------------------------------------------------------------------- +// (c) Consumption vs DevTestConsumption -> default picks Consumption, +// hint picks DevTestConsumption +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_TypeDefaultAndHint(t *testing.T) { + consumption := consumptionItem("meter-c", "eastus", "D4s v3", "Virtual Machines DSv3 Series", 0, 0.192) + devTest := consumptionItem("meter-c", "eastus", "D4s v3 DevTest", "Virtual Machines DSv3 Series", 0, 0.096) + devTest.Type = "DevTestConsumption" + + items := []azureRetailItem{consumption, devTest} + srv := azureSKUServer(t, items) + defer srv.Close() + p := newSKULookupTestProvider(t, srv) + + // No hint: default filters to Type=="Consumption". + result, err := p.LookupSKUAcrossRegionsGeneric(context.Background(), "meter-c", []string{"eastus"}, "", skulookup.SKUHint{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + rr := result.Regions[0] + if rr.Ambiguous { + t.Fatalf("expected unambiguous default match, got ambiguous: %+v", rr) + } + if len(rr.Prices) != 1 || rr.Prices[0].PricePerUnit != 0.192 { + t.Fatalf("expected the Consumption row (0.192), got %+v", rr.Prices) + } + + // hint="DevTestConsumption": picks the other row. + result, err = p.LookupSKUAcrossRegionsGeneric(context.Background(), "meter-c", []string{"eastus"}, "", + skulookup.SKUHint{ProductFamilyHint: "DevTestConsumption"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + rr = result.Regions[0] + if rr.Ambiguous { + t.Fatalf("expected unambiguous hinted match, got ambiguous: %+v", rr) + } + if len(rr.Prices) != 1 || rr.Prices[0].PricePerUnit != 0.096 { + t.Fatalf("expected the DevTestConsumption row (0.096), got %+v", rr.Prices) + } + if rr.HintStatus != skulookup.HintStatusResolved { + t.Errorf("expected HintStatusResolved, got %q", rr.HintStatus) + } +} + +// -------------------------------------------------------------------------- +// (d) genuine tiered/graduated fixture -> lowest tier selected, not +// ambiguous +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_GenuineTierLadder(t *testing.T) { + items := []azureRetailItem{ + consumptionItem("meter-d", "eastus", "S1 Blob Storage", "Blob Storage", 0, 0.0184), + consumptionItem("meter-d", "eastus", "S1 Blob Storage", "Blob Storage", 51200, 0.0177), + consumptionItem("meter-d", "eastus", "S1 Blob Storage", "Blob Storage", 512000, 0.017), + } + srv := azureSKUServer(t, items) + defer srv.Close() + p := newSKULookupTestProvider(t, srv) + + result, err := p.LookupSKUAcrossRegionsGeneric(context.Background(), "meter-d", []string{"eastus"}, "", skulookup.SKUHint{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + rr := result.Regions[0] + if rr.Ambiguous { + t.Fatalf("expected a genuine tier ladder to resolve without ambiguity, got %+v", rr) + } + if !rr.Tiered { + t.Errorf("expected Tiered=true") + } + if len(rr.Prices) != 3 { + t.Fatalf("expected all 3 tiers surfaced, got %d", len(rr.Prices)) + } + if rr.Prices[0].PricePerUnit != 0.0184 { + t.Errorf("expected the base (lowest-tier) row first, got %+v", rr.Prices[0]) + } + // Every tier row must carry tier_start_usage — bom.go's + // gcpGraduatedTieredCost (shared, provider-agnostic) reads only this + // attribute to bracket usage against each tier; without it, a tiered + // Azure SKU silently prices at $0.00/mo (every tier gets skipped by + // tierStartUsage's ok=false path). See azureSKUItemToPrice. + wantStart := []string{"0", "51200", "512000"} + for i, price := range rr.Prices { + if got := price.Attributes["tier_start_usage"]; got != wantStart[i] { + t.Errorf("tier %d: expected tier_start_usage=%q, got %q (attrs %+v)", i, wantStart[i], got, price.Attributes) + } + } +} + +// -------------------------------------------------------------------------- +// (d2) product-family collision under the DEFAULT (no-hint) path: two +// distinct Consumption products sharing tierMinimumUnits=0 must NOT be +// treated as a genuine tier ladder. This is the failure branch of +// resolveAzureTierGroup (len(groups) != 1) — the direct sibling of the +// Reservation-collision regression in scenario (g), but reached via the +// default (no-hint) path instead of an explicit hint, so it exercises +// the group-count guard itself rather than the explicit-hint bypass. +// Per the T41 precedent (docs/plans/T41-sku-lookup.md), a "cheapest" +// tiebreak here would silently return the wrong product at the wrong +// price; both candidates must survive to the Ambiguous report instead. +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_DistinctConsumptionProductsStayAmbiguous(t *testing.T) { + items := []azureRetailItem{ + consumptionItem("meter-d2", "eastus", "D4s v3", "Virtual Machines DSv3 Series", 0, 0.192), + consumptionItem("meter-d2", "eastus", "E4s v3", "Virtual Machines ESv3 Series", 0, 0.252), + } + srv := azureSKUServer(t, items) + defer srv.Close() + p := newSKULookupTestProvider(t, srv) + + result, err := p.LookupSKUAcrossRegionsGeneric(context.Background(), "meter-d2", []string{"eastus"}, "", skulookup.SKUHint{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + rr := result.Regions[0] + if !rr.Ambiguous { + t.Fatalf("expected two distinct Consumption products with no hint to stay Ambiguous rather than be treated as a tier ladder, got %+v", rr) + } + if rr.HintStatus != skulookup.HintStatusAmbiguous { + t.Errorf("expected HintStatusAmbiguous, got %q", rr.HintStatus) + } + if rr.Tiered { + t.Errorf("expected Tiered=false — these are two different products, not tiers of one product") + } + if len(rr.Prices) != 2 { + t.Fatalf("expected both candidates preserved (no arbitrary pick), got %d prices: %+v", len(rr.Prices), rr.Prices) + } +} + +// -------------------------------------------------------------------------- +// (e) primary/non-primary duplicate-region fixture +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_PrimaryMeterRegionDedup(t *testing.T) { + primary := consumptionItem("meter-e", "eastus", "D4s v3", "Virtual Machines DSv3 Series", 0, 0.192) + duplicate := consumptionItem("meter-e", "eastus", "D4s v3", "Virtual Machines DSv3 Series", 0, 0.192) + duplicate.IsPrimaryMeterRegion = false + + items := []azureRetailItem{primary, duplicate} + srv := azureSKUServer(t, items) + defer srv.Close() + p := newSKULookupTestProvider(t, srv) + + result, err := p.LookupSKUAcrossRegionsGeneric(context.Background(), "meter-e", []string{"eastus"}, "", skulookup.SKUHint{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + rr := result.Regions[0] + if rr.Ambiguous { + t.Fatalf("expected the primary/non-primary dedup to resolve without ambiguity, got %+v", rr) + } + if len(rr.Prices) != 1 { + t.Fatalf("expected the duplicate row dropped, got %d prices", len(rr.Prices)) + } + if rr.Prices[0].Attributes["isPrimaryMeterRegion"] != "true" { + t.Errorf("expected the surviving row to be the primary one, got attrs %+v", rr.Prices[0].Attributes) + } +} + +// -------------------------------------------------------------------------- +// (f) Spot fixture: product_family_hint="spot" resolves via meterName +// substring +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_SpotHintMeterNameSubstring(t *testing.T) { + onDemand := consumptionItem("meter-f", "eastus", "D4s v3", "Virtual Machines DSv3 Series", 0, 0.192) + spot := consumptionItem("meter-f", "eastus", "D4s v3 Spot", "Virtual Machines DSv3 Series", 0, 0.05) + spot.MeterName = "D4s v3 Spot" + + items := []azureRetailItem{onDemand, spot} + srv := azureSKUServer(t, items) + defer srv.Close() + p := newSKULookupTestProvider(t, srv) + + result, err := p.LookupSKUAcrossRegionsGeneric(context.Background(), "meter-f", []string{"eastus"}, "", + skulookup.SKUHint{ProductFamilyHint: "spot"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + rr := result.Regions[0] + if rr.Ambiguous { + t.Fatalf("expected the spot hint to resolve without ambiguity, got %+v", rr) + } + if len(rr.Prices) != 1 || rr.Prices[0].PricePerUnit != 0.05 { + t.Fatalf("expected the Spot row (0.05), got %+v", rr.Prices) + } + if rr.HintStatus != skulookup.HintStatusResolved { + t.Errorf("expected HintStatusResolved, got %q", rr.HintStatus) + } +} + +// -------------------------------------------------------------------------- +// (g) THE CRITICAL NEW FIXTURE: Reservation-tier collision -> Ambiguous, +// not an arbitrarily-picked price +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_ReservationTierCollisionStaysAmbiguous(t *testing.T) { + rowA := azureRetailItem{ + RetailPrice: 5048.0, + SkuName: "D4s v3 Reserved", + ArmSkuName: "Standard_D4s_v3", + ProductName: "Virtual Machines DSv3 Series", + MeterName: "D4s v3", + ServiceName: "Virtual Machines", + ServiceFamily: "Compute", + MeterID: "meter-g", + ArmRegionName: "eastus", + UnitOfMeasure: "1 Year", + TierMinimumUnits: 0.0, + Type: "Reservation", + IsPrimaryMeterRegion: true, + } + // A colliding row: same meterId/region/type/isPrimaryMeterRegion/ + // tierMinimumUnits, but a completely different product (different + // SkuName/ProductName) and wildly different RetailPrice — the exact + // live-data shape documented in azure_sku_lookup.go's step-7 doc. + rowB := azureRetailItem{ + RetailPrice: 118201.0, + SkuName: "M128s Reserved", + ArmSkuName: "Standard_M128s", + ProductName: "Virtual Machines Msv2 Series", + MeterName: "M128s", + ServiceName: "Virtual Machines", + ServiceFamily: "Compute", + MeterID: "meter-g", + ArmRegionName: "eastus", + UnitOfMeasure: "3 Years", + TierMinimumUnits: 0.0, + Type: "Reservation", + IsPrimaryMeterRegion: true, + } + + items := []azureRetailItem{rowA, rowB} + srv := azureSKUServer(t, items) + defer srv.Close() + p := newSKULookupTestProvider(t, srv) + + result, err := p.LookupSKUAcrossRegionsGeneric(context.Background(), "meter-g", []string{"eastus"}, "", + skulookup.SKUHint{ProductFamilyHint: "Reservation"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + rr := result.Regions[0] + if !rr.Ambiguous { + t.Fatalf("expected Ambiguous=true for the Reservation tier collision, got a resolved single price: %+v", rr) + } + if len(rr.Prices) != 2 { + t.Fatalf("expected both colliding candidates preserved in Prices, got %d", len(rr.Prices)) + } +} + +// -------------------------------------------------------------------------- +// (g2) resolveAzureTierGroup, exercised DIRECTLY against Reservation-shaped +// colliding rows. In production, resolveAzureTierGroup is only ever +// called from the default (no-hint) path (resolveAzureSKURegion), and +// the no-hint path's own applyAzureSKUHint pre-filters to +// Type=="Consumption" before reaching it — so a Reservation row can +// never structurally reach this function today, and (g)'s end-to-end +// test above exercises the collision only via the coarser +// explicit-hint-always-ambiguous rule (step 6), not this guard itself. +// This test closes that direct-coverage gap: it proves the +// grouping/monotonicity guard would ALSO correctly reject this exact +// live-data collision shape (distinct SkuName/ProductName, identical +// tierMinimumUnits==0.0, wildly different RetailPrice) if it were ever +// reached, without changing resolveAzureSKURegion's call graph or +// weakening the guard itself. +// -------------------------------------------------------------------------- + +func TestResolveAzureTierGroup_ReservationCollisionRejected(t *testing.T) { + rowA := azureRetailItem{ + RetailPrice: 5048.0, + SkuName: "D4s v3 Reserved", + ProductName: "Virtual Machines DSv3 Series", + TierMinimumUnits: 0.0, + Type: "Reservation", + } + rowB := azureRetailItem{ + RetailPrice: 118201.0, + SkuName: "M128s Reserved", + ProductName: "Virtual Machines Msv2 Series", + TierMinimumUnits: 0.0, + Type: "Reservation", + } + + tiers, ok := resolveAzureTierGroup([]azureRetailItem{rowA, rowB}) + if ok { + t.Fatalf("expected resolveAzureTierGroup to reject a collision across distinct (SkuName, ProductName) groups, got ok=true tiers=%+v", tiers) + } + if tiers != nil { + t.Errorf("expected nil tiers on rejection, got %+v", tiers) + } +} + +// TestResolveAzureTierGroup_DuplicateTierMinimumUnitsRejected covers the +// second independent guard inside resolveAzureTierGroup: even within a +// single (SkuName, ProductName) group, a duplicate TierMinimumUnits is not +// a real tier ladder and must not be resolved. +func TestResolveAzureTierGroup_DuplicateTierMinimumUnitsRejected(t *testing.T) { + rowA := azureRetailItem{RetailPrice: 5048.0, SkuName: "D4s v3 Reserved", ProductName: "Virtual Machines DSv3 Series", TierMinimumUnits: 0.0, Type: "Reservation"} + rowB := azureRetailItem{RetailPrice: 118201.0, SkuName: "D4s v3 Reserved", ProductName: "Virtual Machines DSv3 Series", TierMinimumUnits: 0.0, Type: "Reservation"} + + if tiers, ok := resolveAzureTierGroup([]azureRetailItem{rowA, rowB}); ok { + t.Fatalf("expected rejection on duplicate TierMinimumUnits within one group, got ok=true tiers=%+v", tiers) + } +} + +// -------------------------------------------------------------------------- +// (h) hint matches zero rows -> HintStatusNoMatch, fails closed +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_HintMatchesNothingFailsClosed(t *testing.T) { + consumption := consumptionItem("meter-h", "eastus", "D4s v3", "Virtual Machines DSv3 Series", 0, 0.192) + reservation := consumptionItem("meter-h", "eastus", "D4s v3 Reserved", "Virtual Machines DSv3 Series", 0, 1016.16) + reservation.Type = "Reservation" + + items := []azureRetailItem{consumption, reservation} + srv := azureSKUServer(t, items) + defer srv.Close() + p := newSKULookupTestProvider(t, srv) + + result, err := p.LookupSKUAcrossRegionsGeneric(context.Background(), "meter-h", []string{"eastus"}, "", + skulookup.SKUHint{ProductFamilyHint: "DevTestConsumption"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + rr := result.Regions[0] + if !rr.Ambiguous { + t.Fatalf("expected Ambiguous=true when the hint matches nothing, got %+v", rr) + } + if rr.HintStatus != skulookup.HintStatusNoMatch { + t.Errorf("expected HintStatusNoMatch, got %q", rr.HintStatus) + } + if len(rr.Prices) != 2 { + t.Fatalf("expected the original unfiltered candidate set (2 rows) preserved, got %d", len(rr.Prices)) + } +} + +// -------------------------------------------------------------------------- +// (h2) DEFAULT (no-hint) path's own Consumption filter matches zero rows -> +// HintStatusNoHint, NOT HintStatusNoMatch. HintStatusNoMatch is +// documented (skulookup.go) as meaning "a hint was SUPPLIED but +// matched zero rows" — this scenario supplies no hint at all (every +// row here is a Reservation row, so applyAzureSKUHint's own default +// Type=="Consumption" filter is what empties the set, not a caller +// hint), so mislabeling it NoMatch would misreport a hint that was +// never given. +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_NoHintDefaultFilterEmptyIsNoHintNotNoMatch(t *testing.T) { + reservation1 := consumptionItem("meter-h2", "eastus", "D4s v3 Reserved 1yr", "Virtual Machines DSv3 Series", 0, 1016.16) + reservation1.Type = "Reservation" + reservation3 := consumptionItem("meter-h2", "eastus", "D4s v3 Reserved 3yr", "Virtual Machines DSv3 Series", 0, 2500.0) + reservation3.Type = "Reservation" + + items := []azureRetailItem{reservation1, reservation3} + srv := azureSKUServer(t, items) + defer srv.Close() + p := newSKULookupTestProvider(t, srv) + + result, err := p.LookupSKUAcrossRegionsGeneric(context.Background(), "meter-h2", []string{"eastus"}, "", skulookup.SKUHint{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + rr := result.Regions[0] + if !rr.Ambiguous { + t.Fatalf("expected Ambiguous=true when the default Consumption filter matches nothing, got %+v", rr) + } + if rr.HintStatus != skulookup.HintStatusNoHint { + t.Errorf("expected HintStatusNoHint (no hint was supplied), got %q", rr.HintStatus) + } + if len(rr.Prices) != 2 { + t.Fatalf("expected the original unfiltered candidate set (2 rows) preserved, got %d", len(rr.Prices)) + } +} + +// -------------------------------------------------------------------------- +// azureSKUUnit: best-effort UnitOfMeasure -> models.PriceUnit derivation. +// Regression coverage for the previous hardcoded-per-hour default, which +// overstated MonthlyCost() by ~730x for any non-hourly meter (see +// azureSKUUnit's doc comment). +// -------------------------------------------------------------------------- + +func TestAzureSKUUnit(t *testing.T) { + cases := []struct { + name string + item azureRetailItem + want models.PriceUnit + }{ + {"hour", azureRetailItem{UnitOfMeasure: "1 Hour"}, models.PriceUnitPerHour}, + {"gb-month", azureRetailItem{UnitOfMeasure: "1 GB/Month"}, models.PriceUnitPerGBMonth}, + {"gb", azureRetailItem{UnitOfMeasure: "1 GB"}, models.PriceUnitPerGB}, + {"gb-second", azureRetailItem{UnitOfMeasure: "1 GB Second"}, models.PriceUnitPerGBSecond}, + {"month", azureRetailItem{UnitOfMeasure: "1/Month"}, models.PriceUnitPerMonth}, + {"request-by-meter", azureRetailItem{UnitOfMeasure: "10K", MeterName: "Standard Execution"}, models.PriceUnitPerRequest}, + {"operation-by-uom", azureRetailItem{UnitOfMeasure: "10K Operations"}, models.PriceUnitPerOperation}, + // Reservation rows: UnitOfMeasure is a contract-length label, not an + // hourly rate — must NOT fall back to per_hour (that was the bug: + // MonthlyCost() would multiply a total contract price by 730). + {"reservation-1yr", azureRetailItem{UnitOfMeasure: "1 Year"}, models.PriceUnitPerUnit}, + {"reservation-3yr", azureRetailItem{UnitOfMeasure: "3 Years"}, models.PriceUnitPerUnit}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := azureSKUUnit(tc.item); got != tc.want { + t.Errorf("azureSKUUnit(%+v) = %q, want %q", tc.item, got, tc.want) + } + }) + } +} + +// -------------------------------------------------------------------------- +// azureSKUItemTerm: PricingTerm derivation, including Fix #8's "Low +// Priority" spot-substring branch (the pre-fix version only matched +// "spot", so Azure Batch/AKS "Low Priority" meters were misclassified as +// PricingTermOnDemand). +// -------------------------------------------------------------------------- + +func TestAzureSKUItemTerm(t *testing.T) { + cases := []struct { + name string + item azureRetailItem + want models.PricingTerm + }{ + {"spot-by-meter-name", azureRetailItem{MeterName: "D4s v3 Spot", Type: "Consumption"}, models.PricingTermSpot}, + {"low-priority-by-meter-name", azureRetailItem{MeterName: "D4s v3 Low Priority", Type: "Consumption"}, models.PricingTermSpot}, + {"reservation", azureRetailItem{MeterName: "D4s v3", Type: "Reservation"}, models.PricingTermReserved1Yr}, + {"on-demand-consumption", azureRetailItem{MeterName: "D4s v3", Type: "Consumption"}, models.PricingTermOnDemand}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := azureSKUItemTerm(tc.item); got != tc.want { + t.Errorf("azureSKUItemTerm(%+v) = %q, want %q", tc.item, got, tc.want) + } + }) + } +} + +// -------------------------------------------------------------------------- +// Extra: fetch-level error is not reported as NoMapping +// -------------------------------------------------------------------------- + +func TestLookupSKUAcrossRegionsGeneric_FetchErrorNotNoMapping(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + p := newSKULookupTestProvider(t, srv) + + result, err := p.LookupSKUAcrossRegionsGeneric(context.Background(), "meter-err", []string{"eastus"}, "", skulookup.SKUHint{}) + if err != nil { + t.Fatalf("unexpected top-level error: %v", err) + } + rr := result.Regions[0] + if rr.NoMapping { + t.Errorf("a fetch failure must not be reported as NoMapping (that asserts 'checked, not found')") + } + if rr.Error == "" { + t.Errorf("expected a non-empty Error for a fetch failure") + } +} diff --git a/opencloudcosts-go/internal/server/server.go b/opencloudcosts-go/internal/server/server.go index b691e91..8d4fa4d 100644 --- a/opencloudcosts-go/internal/server/server.go +++ b/opencloudcosts-go/internal/server/server.go @@ -3059,9 +3059,9 @@ const ( descComparePrices = "\n Compare pricing for any service across multiple regions.\n\n Fetches concurrently. Returns results sorted cheapest first, with % delta between\n cheapest and most expensive. Optionally shows delta vs a baseline region.\n\n Args:\n spec: PricingSpec dict (same as get_price). The region field is overridden\n per comparison — you can pass any region in the spec.\n regions: List of region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\", \"ap-northeast-1\"]\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n " - descGetPriceBySKU = "\n Resolve a raw AWS usage-type/SKU string — exactly as it appears in a Cost & Usage Report\n (CUR) export, e.g. \"CAN1-BoxUsage:r5a.8xlarge\" — or a raw GCP Cloud Billing Catalog skuId\n string (provider=\"gcp\") to a price, across one or more regions.\n\n Use this instead of get_price/compare_prices when you have a raw billing export line item\n (a \"UsageType\"/\"SKU\" column value, or a GCP skuId) and need to reconcile it against current\n public pricing, rather than starting from a known resource_type/domain spec. This tool strips the\n region-prefix token from the usage-type string (e.g. \"CAN1-\", \"EU-\", or no prefix at all\n for us-east-1) to get a region-independent suffix, then matches that suffix against each\n target region's pricing catalog. (This prefix-stripping step is AWS-only; see the GCP\n paragraph below for how provider=\"gcp\" resolves instead.)\n\n If service is omitted, the AWS servicecode is inferred from the usage-type pattern (e.g.\n \"BoxUsage:\" implies AmazonEC2, \"LCUUsage\" implies AWSELB) — service_source in the response\n indicates \"explicit\" or \"inferred\". If a supplied service hint finds no match but the\n inferred servicecode does (real CUR data isn't always internally consistent — e.g. data-\n transfer usage types sometimes appear against an \"AmazonEC2\" AWS Product column but are\n actually billed under AWSDataTransfer), the tool falls back to the inferred match and flags\n service_mismatch on that region's result rather than reporting no match.\n\n Some usage-type suffixes are shared by multiple distinct billable products (e.g. ELB's\n \"LCUUsage\" suffix matches Application/Network/Gateway load balancer pricing alike; RDS's\n \"InstanceUsage:\" suffix matches every database engine on that instance type). When\n that happens the affected region is reported under ambiguous_in (NOT in\n all_regions_sorted/cheapest_price/most_expensive_price — an ambiguous multi-product match\n is never silently resolved to \"cheapest\"), with every candidate row listed under\n alternate_matches. Pass operation and/or product_family — the same columns a CUR export\n carries alongside the usage-type/SKU column — to resolve it: e.g. for an Application Load\n Balancer LCU usage-type, product_family=\"Load Balancer-Application\" picks the correct row\n out of the Application/Network/Gateway alternatives.\n\n Regions with no catalog entry for the resolved suffix are reported in no_mapping_in\n (checked, not found) — this is distinct from errors_in (the catalog fetch itself failed)\n and from ambiguous_in (matched, but more than one product row and not yet resolved).\n Known limitation: compound inter-region/wavelength data-transfer SKUs with two region-\n shaped tokens (e.g. \"USE1WL1ATL1-CAN1-AWS-Out-Bytes\") are not fully resolved by the\n single-prefix-strip model; these produce a warning rather than a silently wrong match.\n\n For provider=\"gcp\": sku is a Cloud Billing Catalog skuId (e.g. \"D041-9EFB-5FA5\"), matched\n exactly (no prefix-stripping) against the service hint's catalog if given, or every\n onboarded service's catalog if service is omitted. operation/product_family hints are AWS-only and\n ignored for GCP — a matched skuId is unambiguous, so ambiguous_in does not apply; instead\n some GCP SKUs are usage-volume tiered (result entries carry \"tiered\": true plus an\n \"all_tier_rates\" array; the entry's own price_per_unit is the lowest tier's rate). GCP's\n service_source is \"explicit\" (service given) or \"scanned_all\" (no hint — every onboarded\n service's catalog is searched) rather than AWS's \"inferred\".\n\n Args:\n provider: Cloud provider — \"aws\" (raw usage-type SKUs) or \"gcp\" (raw Cloud Billing\n Catalog skuId strings). Defaults to \"aws\".\n sku: The raw usage-type/SKU string exactly as it appears in the CUR export (AWS), or\n the raw Cloud Billing Catalog skuId string (GCP).\n service: Optional service hint. For AWS, a servicecode (e.g. \"AmazonEC2\", \"AWSELB\",\n \"AmazonRDS\", \"AmazonDynamoDB\", \"AmazonElastiCache\", \"AWSDataTransfer\") — if\n omitted, it is inferred from the usage-type pattern. For GCP, one of the\n onboarded service names (e.g. \"compute\", \"gcs\", \"cloudsql\", \"gke\",\n \"memorystore\", \"kms\", \"dns\", \"firestore\", \"pubsub\", \"vertex\", \"bigquery\",\n \"monitoring\", \"armor\") — if omitted, every onboarded service is searched.\n regions: List of region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"] (AWS) or\n [\"us-central1\"] (GCP). Required, max 30.\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n operation: Optional AWS-only disambiguating hint — the AWS product \"operation\"\n attribute (e.g. \"CreateDBInstance:0021\" identifies Aurora PostgreSQL among\n RDS engines on the same instance type), matched case-insensitively. Use this\n when a region comes back in ambiguous_in. Ignored for provider=\"gcp\".\n product_family: Optional AWS-only disambiguating hint — the AWS top-level\n \"productFamily\" (e.g. \"Load Balancer-Application\" for an ALB vs\n NLB/GLB), matched case-insensitively. Use this when a region comes back\n in ambiguous_in. Ignored for provider=\"gcp\".\n\n Examples:\n {\"sku\": \"CAN1-BoxUsage:r5a.8xlarge\", \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n {\"sku\": \"CAN1-AWS-Out-Bytes\", \"service\": \"AmazonEC2\", \"regions\": [\"ca-central-1\"]}\n {\"sku\": \"CAN1-LCUUsage\", \"service\": \"AWSELB\", \"product_family\": \"Load Balancer-Application\",\n \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n {\"provider\": \"gcp\", \"sku\": \"D041-9EFB-5FA5\", \"regions\": [\"us-central1\", \"europe-west1\"]}\n " + descGetPriceBySKU = "\n Resolve a raw AWS usage-type/SKU string — exactly as it appears in a Cost & Usage Report\n (CUR) export, e.g. \"CAN1-BoxUsage:r5a.8xlarge\" — a raw GCP Cloud Billing Catalog skuId\n string (provider=\"gcp\"), or a raw Azure Retail Prices API meterId string (provider=\"azure\",\n a GUID, e.g. \"00000000-0000-0000-0000-000000000000\") to a price, across one or more\n regions.\n\n Use this instead of get_price/compare_prices when you have a raw billing export line item\n (a \"UsageType\"/\"SKU\" column value, or a GCP skuId) and need to reconcile it against current\n public pricing, rather than starting from a known resource_type/domain spec. This tool strips the\n region-prefix token from the usage-type string (e.g. \"CAN1-\", \"EU-\", or no prefix at all\n for us-east-1) to get a region-independent suffix, then matches that suffix against each\n target region's pricing catalog. (This prefix-stripping step is AWS-only; see the GCP and\n Azure paragraphs below for how provider=\"gcp\"/provider=\"azure\" resolve instead.)\n\n If service is omitted, the AWS servicecode is inferred from the usage-type pattern (e.g.\n \"BoxUsage:\" implies AmazonEC2, \"LCUUsage\" implies AWSELB) — service_source in the response\n indicates \"explicit\" or \"inferred\". If a supplied service hint finds no match but the\n inferred servicecode does (real CUR data isn't always internally consistent — e.g. data-\n transfer usage types sometimes appear against an \"AmazonEC2\" AWS Product column but are\n actually billed under AWSDataTransfer), the tool falls back to the inferred match and flags\n service_mismatch on that region's result rather than reporting no match.\n\n Some usage-type suffixes are shared by multiple distinct billable products (e.g. ELB's\n \"LCUUsage\" suffix matches Application/Network/Gateway load balancer pricing alike; RDS's\n \"InstanceUsage:\" suffix matches every database engine on that instance type). When\n that happens the affected region is reported under ambiguous_in (NOT in\n all_regions_sorted/cheapest_price/most_expensive_price — an ambiguous multi-product match\n is never silently resolved to \"cheapest\"), with every candidate row listed under\n alternate_matches. Pass operation and/or product_family — the same columns a CUR export\n carries alongside the usage-type/SKU column — to resolve it: e.g. for an Application Load\n Balancer LCU usage-type, product_family=\"Load Balancer-Application\" picks the correct row\n out of the Application/Network/Gateway alternatives.\n\n Regions with no catalog entry for the resolved suffix are reported in no_mapping_in\n (checked, not found) — this is distinct from errors_in (the catalog fetch itself failed)\n and from ambiguous_in (matched, but more than one product row and not yet resolved).\n Known limitation: compound inter-region/wavelength data-transfer SKUs with two region-\n shaped tokens (e.g. \"USE1WL1ATL1-CAN1-AWS-Out-Bytes\") are not fully resolved by the\n single-prefix-strip model; these produce a warning rather than a silently wrong match.\n\n For provider=\"gcp\": sku is a Cloud Billing Catalog skuId (e.g. \"D041-9EFB-5FA5\"), matched\n exactly (no prefix-stripping) against the service hint's catalog if given, or every\n onboarded service's catalog if service is omitted. operation/product_family hints are AWS-only and\n ignored for GCP — a matched skuId is unambiguous, so ambiguous_in does not apply; instead\n some GCP SKUs are usage-volume tiered (result entries carry \"tiered\": true plus an\n \"all_tier_rates\" array; the entry's own price_per_unit is the lowest tier's rate). GCP's\n service_source is \"explicit\" (service given) or \"scanned_all\" (no hint — every onboarded\n service's catalog is searched) rather than AWS's \"inferred\".\n\n For provider=\"azure\": sku is an Azure Retail Prices API meterId (a GUID), matched exactly\n (no prefix-stripping, like GCP) against the region's catalog. service is not used (the\n meterId itself is looked up directly) and operation is ignored — Azure has no equivalent\n hint. product_family carries AZURE-SPECIFIC meaning here, distinct from the AWS\n productFamily/GCP cases above: pass one of \"Consumption\", \"DevTestConsumption\", or\n \"Reservation\" to match against the row's type field (case-insensitively), OR pass the\n literal value \"spot\" to match against the row's meterName instead of type (Azure spot rows\n are Consumption-type rows whose meterName contains \"Spot\", not a distinct type value) —\n do not assume \"spot\" resolves the same way as the type-equality hints. Some Azure\n Reservation-type meterId matches span more than one genuinely distinct billable product and\n cannot be safely disambiguated by this tool even with a hint; those are reported under\n ambiguous_in rather than guessed.\n\n Args:\n provider: Cloud provider — \"aws\" (raw usage-type SKUs), \"gcp\" (raw Cloud Billing\n Catalog skuId strings), or \"azure\" (raw Retail Prices API meterId strings).\n Defaults to \"aws\".\n sku: The raw usage-type/SKU string exactly as it appears in the CUR export (AWS), the\n raw Cloud Billing Catalog skuId string (GCP), or the raw Retail Prices API meterId\n GUID string (Azure).\n service: Optional service hint. For AWS, a servicecode (e.g. \"AmazonEC2\", \"AWSELB\",\n \"AmazonRDS\", \"AmazonDynamoDB\", \"AmazonElastiCache\", \"AWSDataTransfer\") — if\n omitted, it is inferred from the usage-type pattern. For GCP, one of the\n onboarded service names (e.g. \"compute\", \"gcs\", \"cloudsql\", \"gke\",\n \"memorystore\", \"kms\", \"dns\", \"firestore\", \"pubsub\", \"vertex\", \"bigquery\",\n \"monitoring\", \"armor\") — if omitted, every onboarded service is searched.\n Unused for Azure (the meterId is looked up directly).\n regions: List of region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"] (AWS),\n [\"us-central1\"] (GCP), or [\"eastus\"] (Azure). Required, max 30.\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n operation: Optional AWS-only disambiguating hint — the AWS product \"operation\"\n attribute (e.g. \"CreateDBInstance:0021\" identifies Aurora PostgreSQL among\n RDS engines on the same instance type), matched case-insensitively. Use this\n when a region comes back in ambiguous_in. Ignored for provider=\"gcp\" or\n provider=\"azure\" (Azure has no equivalent hint).\n product_family: Optional disambiguating hint whose meaning is provider-specific. For\n AWS, the AWS top-level \"productFamily\" (e.g. \"Load Balancer-Application\"\n for an ALB vs NLB/GLB), matched case-insensitively. Ignored for\n provider=\"gcp\". For provider=\"azure\", pass \"Consumption\",\n \"DevTestConsumption\", or \"Reservation\" to match the row's type field, or\n the literal \"spot\" to match the row's meterName instead (NOT resolved\n the same way as the type values — see the Azure paragraph above). Use\n this when a region comes back in ambiguous_in.\n\n Examples:\n {\"sku\": \"CAN1-BoxUsage:r5a.8xlarge\", \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n {\"sku\": \"CAN1-AWS-Out-Bytes\", \"service\": \"AmazonEC2\", \"regions\": [\"ca-central-1\"]}\n {\"sku\": \"CAN1-LCUUsage\", \"service\": \"AWSELB\", \"product_family\": \"Load Balancer-Application\",\n \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n {\"provider\": \"gcp\", \"sku\": \"D041-9EFB-5FA5\", \"regions\": [\"us-central1\", \"europe-west1\"]}\n {\"provider\": \"azure\", \"sku\": \"00000000-0000-0000-0000-000000000000\", \"regions\": [\"eastus\", \"westeurope\"]}\n " - descGetPricesBySKU = "\n Batch form of get_price_by_sku: resolve many raw AWS usage-type/SKU strings — each exactly\n as it appears in a Cost & Usage Report (CUR) export — or many raw GCP Cloud Billing Catalog\n skuId strings (provider=\"gcp\") — against the same set of target regions in one call.\n\n Use this to reconcile many CUR line items (or GCP skuIds) at once instead of issuing one\n get_price_by_sku call per SKU. Each sku is resolved independently via the same logic\n get_price_by_sku uses, so per-region ambiguous_in/no_mapping_in/errors_in bucketing (AWS),\n tiered/all_tier_rates (GCP), and baseline_region deltas all apply per sku exactly as they\n would in a standalone get_price_by_sku call — this tool only adds the batching and\n aggregation layer on top.\n\n service/operation/product_family hints are not supported here (they are inherently\n per-sku, and different SKUs in a batch usually resolve to different services) — for AWS the\n servicecode is inferred per sku from its usage-type pattern; for GCP every onboarded\n service's catalog is searched per sku. If a particular sku needs a hint to resolve an\n ambiguous_in entry (AWS) or to narrow the search (GCP), follow up with a single\n get_price_by_sku call for that sku, passing service and, for AWS, operation/product_family.\n\n Each successfully-processed sku appears in \"results\", in the same order as the input skus\n list (NOT re-sorted by price — distinct SKUs commonly price in different units, e.g.\n per-hour vs per-GB vs per-request, that are not meaningfully comparable). A sku that fails\n outright (e.g. an empty string, or a usage-type pattern no service could be inferred for)\n is instead reported in the top-level \"errors\" map, keyed by that sku string, with\n message/status/retryable fields mirroring get_prices_batch's per-item error shape.\n\n Args:\n provider: Cloud provider — \"aws\" (raw usage-type SKUs) or \"gcp\" (raw Cloud Billing\n Catalog skuId strings). Defaults to \"aws\".\n skus: List of raw usage-type/SKU strings (AWS) or skuId strings (GCP). Required, max 25.\n regions: List of region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"] (AWS) or\n [\"us-central1\"] (GCP). Required, max 30 (applies to every sku).\n baseline_region: Optional region for delta comparison, applied to every sku,\n e.g. \"us-east-1\".\n\n Examples:\n {\"skus\": [\"CAN1-BoxUsage:r5a.8xlarge\", \"USW2-BoxUsage:m5.large\"], \"regions\": [\"us-east-1\", \"ca-central-1\"]}\n {\"provider\": \"gcp\", \"skus\": [\"D041-9EFB-5FA5\"], \"regions\": [\"us-central1\", \"europe-west1\"]}\n " + descGetPricesBySKU = "\n Batch form of get_price_by_sku: resolve many raw AWS usage-type/SKU strings — each exactly\n as it appears in a Cost & Usage Report (CUR) export — many raw GCP Cloud Billing Catalog\n skuId strings (provider=\"gcp\"), or many raw Azure Retail Prices API meterId strings\n (provider=\"azure\") — against the same set of target regions in one call.\n\n Use this to reconcile many CUR line items (or GCP skuIds/Azure meterIds) at once instead\n of issuing one get_price_by_sku call per SKU. Each sku is resolved independently via the\n same logic get_price_by_sku uses, so per-region ambiguous_in/no_mapping_in/errors_in\n bucketing (AWS and Azure), tiered/all_tier_rates (GCP), and baseline_region deltas all\n apply per sku exactly as they would in a standalone get_price_by_sku call — this tool only\n adds the batching and aggregation layer on top.\n\n service/operation/product_family hints are not supported here (they are inherently\n per-sku, and different SKUs in a batch usually resolve to different services) — for AWS the\n servicecode is inferred per sku from its usage-type pattern; for GCP every onboarded\n service's catalog is searched per sku; for Azure the meterId is looked up directly and any\n Reservation-type collision that get_price_by_sku's product_family hint could otherwise\n resolve is instead reported ambiguous. If a particular sku needs a hint to resolve an\n ambiguous_in entry (AWS or Azure) or to narrow the search (GCP), follow up with a single\n get_price_by_sku call for that sku, passing service and, for AWS/Azure, operation/\n product_family.\n\n Each successfully-processed sku appears in \"results\", in the same order as the input skus\n list (NOT re-sorted by price — distinct SKUs commonly price in different units, e.g.\n per-hour vs per-GB vs per-request, that are not meaningfully comparable). A sku that fails\n outright (e.g. an empty string, or a usage-type pattern no service could be inferred for)\n is instead reported in the top-level \"errors\" map, keyed by that sku string, with\n message/status/retryable fields mirroring get_prices_batch's per-item error shape.\n\n Args:\n provider: Cloud provider — \"aws\" (raw usage-type SKUs), \"gcp\" (raw Cloud Billing\n Catalog skuId strings), or \"azure\" (raw Retail Prices API meterId strings).\n Defaults to \"aws\".\n skus: List of raw usage-type/SKU strings (AWS), skuId strings (GCP), or meterId GUID\n strings (Azure). Required, max 25.\n regions: List of region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"] (AWS),\n [\"us-central1\"] (GCP), or [\"eastus\"] (Azure). Required, max 30 (applies to\n every sku).\n baseline_region: Optional region for delta comparison, applied to every sku,\n e.g. \"us-east-1\".\n\n Examples:\n {\"skus\": [\"CAN1-BoxUsage:r5a.8xlarge\", \"USW2-BoxUsage:m5.large\"], \"regions\": [\"us-east-1\", \"ca-central-1\"]}\n {\"provider\": \"gcp\", \"skus\": [\"D041-9EFB-5FA5\"], \"regions\": [\"us-central1\", \"europe-west1\"]}\n {\"provider\": \"azure\", \"skus\": [\"00000000-0000-0000-0000-000000000000\"], \"regions\": [\"eastus\", \"westeurope\"]}\n " descSearchPricing = "Deprecated helper that redirects to the correct tools. Use describe_catalog to browse available services by domain/provider, or get_price with a known spec." @@ -3077,7 +3077,7 @@ const ( descDescribeCatalog = "\n Discover what each provider supports and how to call get_price.\n\n - No args → full support matrix across all configured providers.\n - provider only → all domains/services for that provider.\n - provider + domain [+ service] → targeted guidance with required_fields,\n supported_terms, filter_hints, and a ready-to-use example_invocation\n you can pass directly to get_price.\n\n Use this before get_price when unsure of exact field names or values.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\". Empty = all providers.\n domain: Domain — \"compute\", \"storage\", \"database\", \"ai\", \"container\",\n \"serverless\", \"analytics\", \"network\", \"observability\". Empty = all.\n service: Service — e.g. \"bedrock\", \"rds\", \"gke\", \"bigquery\". Empty = all.\n " - descCompareBOMRegions = "\n Compare a Bill of Materials' total monthly cost across multiple regions.\n\n v1 scope: PricingSpec-dict items are AWS-only. Each item is an open PricingSpec dict, same\n shape as estimate_bom's items (provider, domain, resource_type/region/etc, plus\n quantity/hours_per_month/size_gb/description) — or a raw-SKU dict (sku, region, plus\n optional service/operation/product_family) for a CUR usage-type/SKU string (provider=\"aws\"\n or omitted) or a GCP Cloud Billing Catalog skuId string (provider=\"gcp\"; operation/\n product_family are ignored for GCP). The region field on each item is overridden per\n comparison — pass any region in the item dicts. A region's region_name is only populated\n from the region-code display maps when every resolvable item in the call shares one\n provider; a mixed-provider call (e.g. an AWS item and a GCP item together) falls back to\n the bare region code instead of guessing whose naming applies.\n Weighting and a providers filter are not supported yet. Unsupported items\n (a PricingSpec-dict item naming a non-AWS provider, or a raw-SKU item naming a provider\n other than aws/gcp) are reported once under \"not_supported\" rather than guessed or dropped\n silently; full GCP/Azure PricingSpec-dict support is tracked separately.\n\n Returns regions[] sorted cheapest-first, each with total_monthly, the\n resolved line_items, and any per-item errors. Optionally shows delta vs\n a baseline region.\n\n Args:\n items: List of PricingSpec dicts (same shape as estimate_bom, AWS-only) — or\n raw-SKU dicts (sku, region, plus optional service/operation/\n product_family), provider \"aws\" (default) or \"gcp\". See estimate_bom for full\n item format.\n regions: List of region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\"].\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n " + descCompareBOMRegions = "\n Compare a Bill of Materials' total monthly cost across multiple regions.\n\n v1 scope: PricingSpec-dict items are AWS-only. Each item is an open PricingSpec dict, same\n shape as estimate_bom's items (provider, domain, resource_type/region/etc, plus\n quantity/hours_per_month/size_gb/description) — or a raw-SKU dict (sku, region, plus\n optional service/operation/product_family) for a CUR usage-type/SKU string (provider=\"aws\"\n or omitted), a GCP Cloud Billing Catalog skuId string (provider=\"gcp\"; operation/\n product_family are ignored for GCP), or an Azure Retail Prices API meterId string\n (provider=\"azure\"; operation is ignored, product_family has Azure-specific meaning — see\n get_price_by_sku). The region field on each item is overridden per comparison — pass any\n region in the item dicts. A region's region_name is only populated from the region-code\n display maps when every resolvable item in the call shares one provider; a mixed-provider\n call (e.g. an AWS item and a GCP item together) falls back to the bare region code instead\n of guessing whose naming applies.\n Weighting and a providers filter are not supported yet. Unsupported items\n (a PricingSpec-dict item naming a non-AWS provider, or a raw-SKU item naming a provider\n other than aws/gcp/azure) are reported once under \"not_supported\" rather than guessed or\n dropped silently; full GCP/Azure PricingSpec-dict support is tracked separately.\n\n Returns regions[] sorted cheapest-first, each with total_monthly, the\n resolved line_items, and any per-item errors. Optionally shows delta vs\n a baseline region.\n\n Args:\n items: List of PricingSpec dicts (same shape as estimate_bom, AWS-only) — or\n raw-SKU dicts (sku, region, plus optional service/operation/\n product_family), provider \"aws\" (default), \"gcp\", or \"azure\". See estimate_bom\n for full item format.\n regions: List of region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\"].\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n " descGetCoverage = "\n Report which domains/services this server actually covers, per provider.\n\n v1 scope: structural coverage from the catalog only — each domain is\n reported as \"catalog\" (with its known services) unless the provider\n has no entry for it at all. This does NOT fan out a live get_price call\n per region — whether a specific region's live price is a real catalog\n rate or a degraded fallback constant is only observable by calling\n get_price for that spec and checking its \"fallback\" field, since that\n is a live fetch outcome rather than a fixed property of the catalog.\n\n Use this to answer \"what does this server know about\" before trial-\n and-error against describe_catalog and individual get_price calls.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\". Empty = all\n configured providers.\n " @@ -3089,7 +3089,7 @@ const ( descWarmCache = "\n Pre-populate the pricing cache for a provider before a large sweep (e.g. a\n multi-region compare_prices or get_prices_batch call), so that sweep hits a warm\n cache instead of paying fetch latency on every combination.\n\n Resolves each requested service to its catalog example_invocation (the same data\n describe_catalog returns) and fans the resulting spec x region combinations out\n concurrently, mirroring the compare_prices/get_prices_batch fan-out pattern.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\"\n regions: List of region codes to warm, e.g. [\"us-east-1\", \"eu-west-1\"]\n services: Optional list of service names or describe_catalog keys, e.g.\n [\"ec2\", \"rds\", \"compute/fargate\"]. Omit to warm every service the\n provider's catalog has an example invocation for.\n " - descEstimateBOM = "\n Use this tool for total infrastructure cost, TCO, monthly spend for a multi-resource\n stack, or cost comparison between architectures.\n\n Handles compute + storage + database + AI together in a single call — do NOT call\n get_price individually for multi-resource questions; use this tool instead.\n\n Returns per-item and total monthly/annual costs with real public pricing data,\n plus a not_included list of supplementary costs (egress, load balancers, monitoring).\n These are SUPPLEMENTARY — only price them if the user asked for TCO; for most\n questions just note 'additional costs may apply'.\n\n Each item should be a PricingSpec dict PLUS a quantity field:\n - provider: \"aws\" | \"gcp\" | \"azure\"\n - domain: \"compute\" | \"storage\" | \"database\" | \"ai\" | ...\n - region: region code\n - quantity: number of units (default 1)\n - hours_per_month: hours/month for compute (default 730 = always-on)\n - description: optional label for this line item\n Plus domain-specific fields (see get_price or describe_catalog for details).\n\n An item may instead be a raw-SKU dict: {\"sku\": \"...\",\n \"region\": \"us-east-1\", \"quantity\": 3} — the same raw CUR usage-type/SKU string (provider\n \"aws\", default) or GCP Cloud Billing Catalog skuId string (provider \"gcp\") get_price_by_sku\n resolves, optionally with service/operation/product_family hints to disambiguate\n (operation/product_family are AWS-only; ignored for provider \"gcp\"). A GCP SKU with\n usage-volume tiers is costed at the tier matching this item's quantity.\n\n Examples:\n Compute + database + storage on AWS:\n [\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\", \"quantity\": 3},\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r6g.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"},\n {\"provider\": \"aws\", \"domain\": \"storage\", \"storage_type\": \"gp3\", \"size_gb\": 500, \"region\": \"us-east-1\"}\n ]\n\n Mixed cloud:\n [\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"quantity\": 2},\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\", \"quantity\": 1}\n ]\n " + descEstimateBOM = "\n Use this tool for total infrastructure cost, TCO, monthly spend for a multi-resource\n stack, or cost comparison between architectures.\n\n Handles compute + storage + database + AI together in a single call — do NOT call\n get_price individually for multi-resource questions; use this tool instead.\n\n Returns per-item and total monthly/annual costs with real public pricing data,\n plus a not_included list of supplementary costs (egress, load balancers, monitoring).\n These are SUPPLEMENTARY — only price them if the user asked for TCO; for most\n questions just note 'additional costs may apply'.\n\n Each item should be a PricingSpec dict PLUS a quantity field:\n - provider: \"aws\" | \"gcp\" | \"azure\"\n - domain: \"compute\" | \"storage\" | \"database\" | \"ai\" | ...\n - region: region code\n - quantity: number of units (default 1)\n - hours_per_month: hours/month for compute (default 730 = always-on)\n - description: optional label for this line item\n Plus domain-specific fields (see get_price or describe_catalog for details).\n\n An item may instead be a raw-SKU dict: {\"sku\": \"...\",\n \"region\": \"us-east-1\", \"quantity\": 3} — the same raw CUR usage-type/SKU string (provider\n \"aws\", default), GCP Cloud Billing Catalog skuId string (provider \"gcp\"), or Azure Retail\n Prices API meterId string (provider \"azure\") get_price_by_sku resolves, optionally with\n service/operation/product_family hints to disambiguate (operation is AWS-only, ignored for\n provider \"gcp\"/\"azure\"; product_family is AWS-only for the productFamily-matching behavior\n described in get_price_by_sku, but carries different Azure-specific meaning — see\n get_price_by_sku — for provider \"azure\", and is ignored for provider \"gcp\"). A GCP SKU with\n usage-volume tiers is costed at the tier matching this item's quantity.\n\n Examples:\n Compute + database + storage on AWS:\n [\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\", \"quantity\": 3},\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r6g.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"},\n {\"provider\": \"aws\", \"domain\": \"storage\", \"storage_type\": \"gp3\", \"size_gb\": 500, \"region\": \"us-east-1\"}\n ]\n\n Mixed cloud:\n [\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"quantity\": 2},\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\", \"quantity\": 1}\n ]\n " descEstimateUnitEconomics = "\n Estimate per-unit economics (cost per user, per request, per transaction) given\n a Bill of Materials and expected monthly usage volume.\n\n Args:\n items: Same format as estimate_bom — list of cloud resource PricingSpec dicts\n plus quantity field. See estimate_bom for full item format.\n units_per_month: Monthly volume being measured (e.g. 10000 users)\n unit_label: What the unit represents — \"user\", \"request\", \"transaction\", etc.\n " diff --git a/opencloudcosts-go/internal/tools/bom.go b/opencloudcosts-go/internal/tools/bom.go index ee6c363..7809b0f 100644 --- a/opencloudcosts-go/internal/tools/bom.go +++ b/opencloudcosts-go/internal/tools/bom.go @@ -273,8 +273,8 @@ func (li bomLineItem) toMap() map[string]any { // whether one was present (a whitespace-only value does not count). Shared // by processBOMItems and HandleCompareBOMRegions's partition loop // (compare_bom_regions.go) so both treat "is this a raw-SKU item" — and the -// exact string handed to the resolved (AWS or GCP) SKU lookup provider — -// identically. +// exact string handed to the resolved (AWS, GCP, or Azure) SKU lookup +// provider — identically. func rawBOMSKU(item map[string]any) (string, bool) { sku, _ := item["sku"].(string) sku = strings.TrimSpace(sku) diff --git a/opencloudcosts-go/internal/tools/bom_test.go b/opencloudcosts-go/internal/tools/bom_test.go index e8f8165..faad129 100644 --- a/opencloudcosts-go/internal/tools/bom_test.go +++ b/opencloudcosts-go/internal/tools/bom_test.go @@ -1625,6 +1625,61 @@ func TestEstimateBOM_GCPRawSKUItem(t *testing.T) { } } +// -------------------------------------------------------------------------- +// Azure raw-SKU BoM items (SKU-lookup-tool wiring, third provider) +// -------------------------------------------------------------------------- + +// TestEstimateBOM_AzureRawSKUItem verifies an Azure raw-SKU BoM item +// (a Retail Prices API meterId) resolves against a real +// *azureprovider.Provider and contributes to the BoM total — the Azure +// counterpart to TestEstimateBOM_GCPRawSKUItem above. +func TestEstimateBOM_AzureRawSKUItem(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(azureSKUFixtureJSON( + "00000000-0000-0000-0000-000000000000", "eastus", "D4s v3", "Virtual Machines Dsv3 Series", "Virtual Machines", 0.192))) + })) + defer server.Close() + realAzure := newAzureSKUTestProvider(server) + h := tools.New(map[string]tools.Provider{"azure": realAzure}) + + items := []map[string]any{ + { + "sku": "00000000-0000-0000-0000-000000000000", + "provider": "azure", + "region": "eastus", + "quantity": float64(1), + }, + } + resp := callEstimateBOM(t, h, items) + + if _, ok := resp["error"]; ok { + t.Fatalf("expected success, got error: %v", resp["error"]) + } + lineItems, ok := resp["line_items"].([]any) + if !ok || len(lineItems) != 1 { + t.Fatalf("expected 1 line item, got %v", resp["line_items"]) + } + li := lineItems[0].(map[string]any) + if li["sku"] != "00000000-0000-0000-0000-000000000000" { + t.Errorf("expected sku field populated, got %v", li["sku"]) + } + if li["provider"] != "azure" { + t.Errorf("expected provider azure, got %v", li["provider"]) + } + + totals, ok := resp["totals"].(map[string]any) + if !ok { + t.Fatalf("expected totals in response, got %v", resp["totals"]) + } + monthly := totals["monthly"].(map[string]any) + // 0.192/hr * 730 hrs/mo (default) * quantity 1 = $140.16/mo. + if monthly["display"] != "$140.16/mo" { + t.Errorf("expected total monthly $140.16/mo, got %v", monthly["display"]) + } +} + // TestEstimateBOM_GCPRawSKUItem_TieredQuantitySelectsCorrectTier verifies // resolveBOMSKUItem's graduated tiered-billing rule (bom.go): each tier's // rate applies only to the slice of usage that falls within that tier's own @@ -1702,3 +1757,96 @@ func TestEstimateBOM_GCPRawSKUItem_TieredQuantitySelectsCorrectTier(t *testing.T t.Errorf("expected high-quantity item to be billed graduated across both tiers ($15.00/mo), got %v", highMonthly["display"]) } } + +// TestEstimateBOM_AzureRawSKUItem_TieredQuantitySelectsCorrectTier is the +// Azure counterpart to TestEstimateBOM_GCPRawSKUItem_TieredQuantitySelects +// CorrectTier, and closes a gap neither TestEstimateBOM_AzureRawSKUItem nor +// azure_sku_lookup_test.go's TestLookupSKUAcrossRegionsGeneric_GenuineTier +// Ladder cover: the latter only asserts that tier_start_usage is present on +// each row (Fix #6), using a fixture hardcoded to UnitOfMeasure "1 Hour" +// (azure_sku_lookup_test.go's consumptionItem helper) — it never proves the +// dollar total actually comes out right, and a per-hour-denominated tier +// ladder compared against GB-denominated thresholds would silently +// mis-bracket every request. This test drives a genuinely GB/Month-billed +// tiered SKU ("1 GB/Month", so azureSKUUnit — Fix #7 — resolves it to +// PriceUnitPerGBMonth, not the previous hardcoded-to-PerHour default) all +// the way through resolveBOMSKUItem/bom.go's rr.Tiered branch, so both the +// unit selection and the tier-bracketing math are proven together, not just +// the attribute's presence. +func TestEstimateBOM_AzureRawSKUItem_TieredQuantitySelectsCorrectTier(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(azureSKUFixtureJSONTiered( + "SKU-AZURE-TIER-BOM", "eastus", "S1 Blob Storage", "Blob Storage", "Storage", + "1 GB/Month", + []azureTierFixture{ + {start: 0, price: 0.10}, // $0.10/GB below 100 GB + {start: 100, price: 0.05}, // $0.05/GB at/above 100 GB + }))) + })) + defer server.Close() + realAzure := newAzureSKUTestProvider(server) + h := tools.New(map[string]tools.Provider{"azure": realAzure}) + + items := []map[string]any{ + { + "sku": "SKU-AZURE-TIER-BOM", + "provider": "azure", + "region": "eastus", + "quantity": float64(1), + "size_gb": float64(50), // below the second tier's start (100 GB) → first/cheapest tier + "description": "low-usage item", + }, + { + "sku": "SKU-AZURE-TIER-BOM", + "provider": "azure", + "region": "eastus", + "quantity": float64(1), + "size_gb": float64(200), // spans both brackets + "description": "high-usage item", + }, + } + resp := callEstimateBOM(t, h, items) + + if _, ok := resp["error"]; ok { + t.Fatalf("expected success, got error: %v", resp["error"]) + } + lineItems, ok := resp["line_items"].([]any) + if !ok || len(lineItems) != 2 { + t.Fatalf("expected 2 line items, got %v", resp["line_items"]) + } + + byDesc := map[string]map[string]any{} + for _, raw := range lineItems { + li := raw.(map[string]any) + byDesc[li["description"].(string)] = li + } + + low := byDesc["low-usage item"] + if low == nil { + t.Fatalf("expected a low-usage item line, got: %v", lineItems) + } + lowMonthly := low["monthly_cost"].(map[string]any) + // 50 GB, entirely within tier 1 ($0.10/GB): 50 * 0.10 = $5.00/mo. If Fix + // #7 regressed (unit fell back to per_hour instead of per_gb_month), this + // would instead come out as 0.10 * 730 hrs * quantity 1 = $73.00/mo. + if lowMonthly["display"] != "$5.00/mo" { + t.Errorf("expected low-usage item to use the first tier ($5.00/mo), got %v", lowMonthly["display"]) + } + + high := byDesc["high-usage item"] + if high == nil { + t.Fatalf("expected a high-usage item line, got: %v", lineItems) + } + highMonthly := high["monthly_cost"].(map[string]any) + // 200 GB spans both brackets under graduated billing: the first 100 GB + // at tier 1's $0.10/GB, plus the remaining 100 GB at tier 2's $0.05/GB: + // 100*0.10 + 100*0.05 = $15.00/mo. If Fix #6 regressed (tier_start_usage + // missing/unset), gcpGraduatedTieredCost would bracket nothing and this + // would come out as $0.00/mo instead — the exact CRITICAL repro Finding + // #6 was filed against. + if highMonthly["display"] != "$15.00/mo" { + t.Errorf("expected high-usage item to be billed graduated across both tiers ($15.00/mo), got %v", highMonthly["display"]) + } +} diff --git a/opencloudcosts-go/internal/tools/compare_bom_regions.go b/opencloudcosts-go/internal/tools/compare_bom_regions.go index 95e82fc..5e18806 100644 --- a/opencloudcosts-go/internal/tools/compare_bom_regions.go +++ b/opencloudcosts-go/internal/tools/compare_bom_regions.go @@ -10,16 +10,17 @@ // items are reported once at the top level, tagged "not_supported", rather // than guessed or dropped silently. // -// Raw-SKU items (RC3-015, GCP parity) additionally accept provider=="gcp" — -// resolveBOMSKUItem (bom.go) already resolves either provider generically via -// resolveSKULookupProviderFromMap, so no per-region plumbing here needs to -// change, only the partitioning check below. Because a single -// compare_bom_regions call's resolvable items can therefore now span more -// than one provider (e.g. an AWS EC2 SKU and a GCP Compute Engine SKU in the -// same BoM), and a region's regionResult aggregates cost across every -// resolvable item for that region, there is no longer one single "the" -// provider to pass to regionDisplayNameFn — see the resolvableProviders -// computation and its use below. +// Raw-SKU items (RC3-015, GCP parity; extended to Azure alongside the +// sku_lookup tool-layer wiring) additionally accept provider=="gcp" or +// provider=="azure" — resolveBOMSKUItem (bom.go) already resolves any of +// these providers generically via resolveSKULookupProviderFromMap, so no +// per-region plumbing here needs to change, only the partitioning check +// below. Because a single compare_bom_regions call's resolvable items can +// therefore now span more than one provider (e.g. an AWS EC2 SKU and a GCP +// Compute Engine SKU in the same BoM), and a region's regionResult +// aggregates cost across every resolvable item for that region, there is no +// longer one single "the" provider to pass to regionDisplayNameFn — see the +// resolvableProviders computation and its use below. package tools import ( @@ -63,24 +64,25 @@ func (h *Handler) HandleCompareBOMRegions( } // Partition items up front: v1 resolves AWS PricingSpec-dict items and - // AWS/GCP raw-SKU items. Unsupported items are reported once (provider - // does not vary per region), not re-derived on every region iteration. + // AWS/GCP/Azure raw-SKU items. Unsupported items are reported once + // (provider does not vary per region), not re-derived on every region + // iteration. var resolvable []map[string]any var notSupported []map[string]any for idx, item := range in.Items { label := fmt.Sprintf("Item %d", idx+1) // Raw-SKU items are implicitly AWS (same default get_price_by_sku - // applies to a missing provider) and, as of RC3-015, also accept an - // explicit provider=="gcp" — resolveBOMSKUItem resolves either - // provider generically. An item naming any other provider is routed - // to notSupported here, exactly like any other unsupported item, - // rather than being rejected once per region inside processBOMItems - // below. + // applies to a missing provider) and also accept an explicit + // provider=="gcp" (RC3-015) or provider=="azure" — resolveBOMSKUItem + // resolves any of these providers generically. An item naming any + // other provider is routed to notSupported here, exactly like any + // other unsupported item, rather than being rejected once per region + // inside processBOMItems below. if _, ok := rawBOMSKU(item); ok { pvdrName, hasPvdr := item["provider"].(string) if !hasPvdr || pvdrName == "" || strings.EqualFold(pvdrName, compareBOMRegionsV1Provider) || - strings.EqualFold(pvdrName, "gcp") { + strings.EqualFold(pvdrName, "gcp") || strings.EqualFold(pvdrName, "azure") { resolvable = append(resolvable, item) continue } @@ -88,7 +90,7 @@ func (h *Handler) HandleCompareBOMRegions( "item": label, "provider": pvdrName, "source": "not_supported", - "reason": "compare_bom_regions raw-SKU items support aws and gcp providers only — this provider is not yet supported.", + "reason": "compare_bom_regions raw-SKU items support aws, gcp, and azure providers only — this provider is not yet supported.", }) continue } diff --git a/opencloudcosts-go/internal/tools/compare_bom_regions_test.go b/opencloudcosts-go/internal/tools/compare_bom_regions_test.go index e4898cf..2f723b6 100644 --- a/opencloudcosts-go/internal/tools/compare_bom_regions_test.go +++ b/opencloudcosts-go/internal/tools/compare_bom_regions_test.go @@ -225,26 +225,27 @@ func TestCompareBOMRegions_RawSKUItem(t *testing.T) { } // TestCompareBOMRegions_RawSKUNonAWSProviderReportedOnce verifies a raw-SKU -// item with an explicit unsupported (non-aws, non-gcp) provider is reported -// once in not_supported (Finding 1 fix), not duplicated once per compared -// region. +// item with an explicit unsupported (non-aws, non-gcp, non-azure) provider is +// reported once in not_supported (Finding 1 fix), not duplicated once per +// compared region. // -// NOTE: this test previously used provider="gcp" as its "unsupported" -// example. As of RC3-015 (GCP raw-SKU parity), "gcp" is legitimately -// accepted at the partition step above (HandleCompareBOMRegions), so it no -// longer exercises the not_supported path — see -// TestCompareBOMRegions_GCPRawSKUItem below for gcp's new (resolvable) -// behavior. This test now uses "azure" (still genuinely unsupported) so it -// continues to guard the not_supported path — and doubles as the regression -// check that widening acceptance to aws/gcp didn't accidentally start -// accepting azure too. +// NOTE: this test previously used provider="gcp", then provider="azure", as +// its "unsupported" example. As of RC3-015 (GCP raw-SKU parity) and this +// step's Azure raw-SKU wiring, both "gcp" and "azure" are legitimately +// accepted at the partition step above (HandleCompareBOMRegions), so neither +// exercises the not_supported path anymore — see +// TestCompareBOMRegions_GCPRawSKUItem and TestCompareBOMRegions_AzureRawSKUItem +// below for their new (resolvable) behavior. This test now uses a +// fictitious provider name so it continues to guard the not_supported path — +// and doubles as the regression check that widening acceptance to +// aws/gcp/azure didn't accidentally start accepting arbitrary providers too. func TestCompareBOMRegions_RawSKUNonAWSProviderReportedOnce(t *testing.T) { pvdr := newRegionPricedProvider(map[string]float64{"us-east-1": 0.192, "us-west-2": 0.150}) h := tools.New(map[string]tools.Provider{"aws": pvdr}) resp := callCompareBOMRegions(t, h, tools.CompareBOMRegionsInput{ Items: []map[string]any{ - {"sku": "BoxUsage:m5.xlarge", "provider": "azure", "service": "AmazonEC2"}, + {"sku": "BoxUsage:m5.xlarge", "provider": "oraclecloud", "service": "AmazonEC2"}, }, Regions: []string{"us-east-1", "us-west-2"}, }) @@ -254,15 +255,15 @@ func TestCompareBOMRegions_RawSKUNonAWSProviderReportedOnce(t *testing.T) { t.Fatalf("expected exactly 1 not_supported entry, got: %v", resp["not_supported"]) } entry := notSupported[0].(map[string]any) - if entry["provider"] != "azure" { - t.Errorf("expected azure in not_supported entry, got %v", entry) + if entry["provider"] != "oraclecloud" { + t.Errorf("expected oraclecloud in not_supported entry, got %v", entry) } regions := resp["regions"].([]any) for _, r := range regions { region := r.(map[string]any) if errs, ok := region["errors"].([]any); ok && len(errs) > 0 { - t.Errorf("expected no per-region errors for the azure raw-SKU item (should be reported once at top level), got: %v in region %v", errs, region["region"]) + t.Errorf("expected no per-region errors for the unsupported-provider raw-SKU item (should be reported once at top level), got: %v in region %v", errs, region["region"]) } } } @@ -317,3 +318,55 @@ func TestCompareBOMRegions_GCPRawSKUItem(t *testing.T) { t.Errorf("expected monthly_cost $29.20/mo, got %v", monthly["display"]) } } + +// TestCompareBOMRegions_AzureRawSKUItem verifies an Azure raw-SKU BoM item +// (a Retail Prices API meterId) resolves per region against a real +// *azureprovider.Provider — the Azure counterpart to +// TestCompareBOMRegions_GCPRawSKUItem above. +func TestCompareBOMRegions_AzureRawSKUItem(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(azureSKUFixtureJSON( + "00000000-0000-0000-0000-000000000000", "eastus", "D4s v3", "Virtual Machines Dsv3 Series", "Virtual Machines", 0.192))) + })) + defer server.Close() + realAzure := newAzureSKUTestProvider(server) + h := tools.New(map[string]tools.Provider{"azure": realAzure}) + + resp := callCompareBOMRegions(t, h, tools.CompareBOMRegionsInput{ + Items: []map[string]any{ + {"sku": "00000000-0000-0000-0000-000000000000", "provider": "azure", "quantity": float64(1)}, + }, + Regions: []string{"eastus"}, + }) + + if _, ok := resp["error"]; ok { + t.Fatalf("expected success, got error: %v", resp["error"]) + } + if notSupported, ok := resp["not_supported"].([]any); ok && len(notSupported) > 0 { + t.Fatalf("expected the azure raw-SKU item to resolve (not not_supported), got: %v", notSupported) + } + + regions, ok := resp["regions"].([]any) + if !ok || len(regions) != 1 { + t.Fatalf("expected 1 region entry, got: %v", resp["regions"]) + } + region := regions[0].(map[string]any) + if region["region"] != "eastus" { + t.Errorf("expected region eastus, got %v", region["region"]) + } + lineItems, ok := region["line_items"].([]any) + if !ok || len(lineItems) != 1 { + t.Fatalf("expected 1 line item for eastus, got: %v", region["line_items"]) + } + li := lineItems[0].(map[string]any) + if li["sku"] != "00000000-0000-0000-0000-000000000000" { + t.Errorf("expected sku field populated, got %v", li["sku"]) + } + monthly := li["monthly_cost"].(map[string]any) + // 0.192/hr * 730 hrs/mo (default) * quantity 1 = $140.16/mo. + if monthly["display"] != "$140.16/mo" { + t.Errorf("expected monthly_cost $140.16/mo, got %v", monthly["display"]) + } +} diff --git a/opencloudcosts-go/internal/tools/lookup_test.go b/opencloudcosts-go/internal/tools/lookup_test.go index 47ba026..532bfb8 100644 --- a/opencloudcosts-go/internal/tools/lookup_test.go +++ b/opencloudcosts-go/internal/tools/lookup_test.go @@ -2829,6 +2829,92 @@ func newGCPSKUTestProvider(t *testing.T, server *httptest.Server) *gcpprovider.P return gcpprovider.NewProviderForTesting(cfg, cm, server.URL, server.Client()) } +// azureSKUFixtureJSON builds a minimal Azure Retail Prices API items-page +// JSON body carrying one Consumption-type meter row for the given +// meterID/region/skuName/productName/serviceName/retail price — the Azure +// raw-SKU-lookup counterpart to gcpSKUCatalogFixtureJSON/skuFixtureJSON, +// used by tests that drive a real *azureprovider.Provider (via SetBaseURL/ +// SetHTTPClient) through a fake single-meter Retail Prices API server. +func azureSKUFixtureJSON(meterID, region, skuName, productName, serviceName string, retailPrice float64) string { + b, _ := json.Marshal(map[string]any{ + "Items": []map[string]any{ + { + "meterId": meterID, + "armRegionName": region, + "skuName": skuName, + "productName": productName, + "meterName": skuName, + "serviceName": serviceName, + "type": "Consumption", + "unitOfMeasure": "1 Hour", + "tierMinimumUnits": 0, + "retailPrice": retailPrice, + "isPrimaryMeterRegion": true, + }, + }, + "NextPageLink": "", + }) + return string(b) +} + +// azureTierFixture is one (tierMinimumUnits, retailPrice) row for +// azureSKUFixtureJSONTiered. +type azureTierFixture struct { + start float64 + price float64 +} + +// azureSKUFixtureJSONTiered is azureSKUFixtureJSON's multi-tier counterpart: +// several rows sharing one meterId/skuName/productName but differing +// tierMinimumUnits/retailPrice, and an explicit unitOfMeasure (rather than +// azureSKUFixtureJSON's hardcoded "1 Hour") so tests can exercise a +// non-hourly unit (e.g. "1 GB/Month") end to end through +// resolveBOMSKUItem/bom.go's rr.Tiered graduated-billing branch — see +// TestEstimateBOM_AzureRawSKUItem_TieredQuantitySelectsCorrectTier, which +// specifically closes the gap between azureSKUUnit (Fix #7) selecting the +// right models.PriceUnit and gcpGraduatedTieredCost/gcpTieredUsageVolume +// (bom.go, shared with GCP) bracketing usage in that same unit's +// denomination — a per_hour-denominated tier ladder over a GB-based +// threshold would silently mis-bracket every request. +func azureSKUFixtureJSONTiered(meterID, region, skuName, productName, serviceName, unitOfMeasure string, tiers []azureTierFixture) string { + items := make([]map[string]any, 0, len(tiers)) + for _, t := range tiers { + items = append(items, map[string]any{ + "meterId": meterID, + "armRegionName": region, + "skuName": skuName, + "productName": productName, + "meterName": skuName, + "serviceName": serviceName, + "type": "Consumption", + "unitOfMeasure": unitOfMeasure, + "tierMinimumUnits": t.start, + "retailPrice": t.price, + "isPrimaryMeterRegion": true, + }) + } + b, _ := json.Marshal(map[string]any{ + "Items": items, + "NextPageLink": "", + }) + return string(b) +} + +// newAzureSKUTestProvider builds a *azureprovider.Provider wired (via +// SetBaseURL/SetHTTPClient) to server, for tests driving raw-SKU tools +// (get_price_by_sku, estimate_bom, compare_bom_regions) end-to-end against a +// real Azure provider without a live network call. Mirrors +// newGCPSKUTestProvider; a nil cache.CacheManager is sufficient because the +// Azure raw-SKU lookup path uses its own bespoke in-process cache +// (azureSKUCatalogCache, internal/providers/azure/azure_sku_lookup.go) +// rather than p.cache. +func newAzureSKUTestProvider(server *httptest.Server) *azureprovider.Provider { + p := azureprovider.NewProvider(nil, 0, 0) + p.SetBaseURL(server.URL) + p.SetHTTPClient(server.Client()) + return p +} + // realAWSProvider returns a *awsprovider.Provider sufficient for DescribeCatalog. // AWS DescribeCatalog is purely static; NewProvider is called with an empty // config so no credentials are required. diff --git a/opencloudcosts-go/internal/tools/sku_lookup.go b/opencloudcosts-go/internal/tools/sku_lookup.go index 7256385..5c4c165 100644 --- a/opencloudcosts-go/internal/tools/sku_lookup.go +++ b/opencloudcosts-go/internal/tools/sku_lookup.go @@ -1,17 +1,18 @@ // sku_lookup.go implements the get_price_by_sku tool: given a raw // provider-native SKU/usage-type string exactly as it appears in a billing -// export (e.g. AWS CUR's "CAN1-BoxUsage:r5a.8xlarge", or a GCP Cloud Billing -// Catalog skuId), resolve its price across a list of target regions. Both AWS -// and GCP are supported (see resolveSKULookupProviderFromMap below); other -// providers (e.g. Azure) are rejected with a structured "unsupported_provider" -// error. +// export (e.g. AWS CUR's "CAN1-BoxUsage:r5a.8xlarge", a GCP Cloud Billing +// Catalog skuId, or an Azure Retail Prices meterId), resolve its price across +// a list of target regions. AWS, GCP, and Azure are all supported (see +// resolveSKULookupProviderFromMap below); any other provider name is rejected +// with a structured "unsupported_provider" error. // // This file is deliberately kept separate from lookup.go: lookup.go only // imports the provider-agnostic internal/providers package, while this file -// must import the concrete internal/providers/aws and internal/providers/gcp -// packages to type-switch each one to the provider-agnostic -// skulookup.SKULookupProvider interface (see resolveSKULookupProviderFromMap). -// Isolating those imports here keeps lookup.go provider-agnostic. +// must import the concrete internal/providers/aws, internal/providers/gcp, +// and internal/providers/azure packages to type-switch each one to the +// provider-agnostic skulookup.SKULookupProvider interface (see +// resolveSKULookupProviderFromMap). Isolating those imports here keeps +// lookup.go provider-agnostic. package tools import ( @@ -27,12 +28,13 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/models" awsprovider "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/providers/aws" + azureprovider "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/providers/azure" gcpprovider "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/providers/gcp" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/skulookup" ) // -------------------------------------------------------------------------- -// GetPriceBySKU — raw provider-native SKU/usage-type lookup (AWS, GCP) +// GetPriceBySKU — raw provider-native SKU/usage-type lookup (AWS, GCP, Azure) // -------------------------------------------------------------------------- // GetPriceBySKUInput is the typed input for the get_price_by_sku tool. @@ -113,12 +115,13 @@ func (h *Handler) HandleGetPriceBySKU( } // The provider map is keyed by the canonical lowercase provider name - // (e.g. "aws"/"gcp", populated in cmd/opencloudcosts/main.go). Lowercase - // the lookup key so a caller passing "AWS" still resolves the provider, - // but pass providerName through to the core function's own validation so - // an unsupported provider (e.g. "azure") produces the core function's - // honest, structured "unsupported_provider" error rather than a generic - // "not configured" message. + // (e.g. "aws"/"gcp"/"azure", populated in cmd/opencloudcosts/main.go). + // Lowercase the lookup key so a caller passing "AWS" still resolves the + // provider, but pass providerName through to the core function's own + // validation so an unsupported provider (e.g. "oci", not one of + // aws/gcp/azure) produces the core function's honest, structured + // "unsupported_provider" error rather than a generic "not configured" + // message. lookupP, errOut := resolveSKULookupProviderFromMap(h.providers, providerName, "get_price_by_sku") if errOut != nil { return errResult(errOut), nil, nil @@ -132,10 +135,10 @@ func (h *Handler) HandleGetPriceBySKU( // remaining callers once get_price_by_sku/get_prices_by_sku/resolveBOMSKUItem // were all migrated to this function). It resolves providerName to any concrete // provider that implements skulookup.SKULookupProvider (today, -// *awsprovider.Provider and *gcpprovider.Provider), rather than only ever -// accepting AWS. get_price_by_sku/get_prices_by_sku (this file) and -// resolveBOMSKUItem (bom.go) use this so raw-SKU lookups work uniformly for -// both providers instead of hardcoding *awsprovider.Provider. +// *awsprovider.Provider, *gcpprovider.Provider, and *azureprovider.Provider), +// rather than only ever accepting AWS. get_price_by_sku/get_prices_by_sku +// (this file) and resolveBOMSKUItem (bom.go) use this so raw-SKU lookups +// work uniformly across providers instead of hardcoding *awsprovider.Provider. func resolveSKULookupProviderFromMap(provs map[string]Provider, providerName, toolName string) (skulookup.SKULookupProvider, map[string]any) { pvdr := provs[strings.ToLower(providerName)] if pvdr == nil { @@ -149,6 +152,8 @@ func resolveSKULookupProviderFromMap(provs map[string]Provider, providerName, to return p, nil case *gcpprovider.Provider: return p, nil + case *azureprovider.Provider: + return p, nil default: return nil, map[string]any{ "error": "unsupported_provider", @@ -426,8 +431,8 @@ func (h *Handler) resolveSKUPriceEntry( // on receiving) both keys, even when the parsed usage-type string happens // to carry no prefix ("") — so gate on provider, not on string-emptiness, // which would otherwise also suppress a legitimately-empty AWS prefix. - // GCP never populates these fields at all, so they're omitted for GCP - // rather than emitted as misleading empty strings. + // GCP and Azure never populate these fields at all, so they're omitted + // for both rather than emitted as misleading empty strings. if strings.EqualFold(providerName, "aws") { out["usage_type_prefix"] = result.UsageTypePrefix out["usage_type_suffix"] = result.UsageTypeSuffix diff --git a/opencloudcosts-go/internal/tools/sku_lookup_test.go b/opencloudcosts-go/internal/tools/sku_lookup_test.go index 081460e..d1d7dcd 100644 --- a/opencloudcosts-go/internal/tools/sku_lookup_test.go +++ b/opencloudcosts-go/internal/tools/sku_lookup_test.go @@ -757,15 +757,21 @@ func TestHandleGetPriceBySKU_WrongProvider(t *testing.T) { } } -// TestHandleGetPriceBySKU_WrongProvider_AWSCoreValidation verifies that a -// provider registered under a non-aws/non-gcp key (e.g. "azure" wired to a -// provider that does NOT implement skulookup.SKULookupProvider, exactly like -// production's real Azure provider) is still rejected with -// "unsupported_provider" — via resolveSKULookupProviderFromMap's type-switch -// default case, not a nil-map miss. +// TestHandleGetPriceBySKU_WrongProvider_TypeSwitchDefault verifies that a +// provider registered under some key, but whose concrete type does NOT +// implement skulookup.SKULookupProvider (mockProvider implements only the +// base tools.Provider interface — see lookup_test.go), is still rejected +// with "unsupported_provider" via resolveSKULookupProviderFromMap's +// type-switch default case, not a nil-map miss. This is a generic +// unrecognized-provider-type scenario; it does not claim to model any real +// provider. AWS, GCP, and Azure are all now genuinely supported by +// get_price_by_sku (each implements skulookup.SKULookupProvider and is +// covered by its own provider's *_sku_lookup_test.go), so the key below is +// deliberately a fictitious, never-registered-in-production provider name +// to avoid implying otherwise. // // NOTE: this test previously registered the same *awsprovider.Provider -// instance under both "aws" and "azure" keys to reach a defense-in-depth +// instance under both "aws" and this key to reach a defense-in-depth // providerName guard inside AWS's own core LookupSKUAcrossRegions (which // rejects providerName values other than "aws"). That guard is no longer // reachable through the provider-agnostic path: LookupSKUAcrossRegionsGeneric @@ -777,13 +783,14 @@ func TestHandleGetPriceBySKU_WrongProvider(t *testing.T) { // interface — a real (if low-impact, since production only ever registers // each provider under its own canonical key) regression introduced by the // RC3-015 hoist, flagged here rather than papered over. This test now -// exercises the guard that actually enforces the "azure" rejection in -// production: the provs-map type switch in resolveSKULookupProviderFromMap. -func TestHandleGetPriceBySKU_WrongProvider_AWSCoreValidation(t *testing.T) { - h := tools.New(map[string]tools.Provider{"azure": &mockProvider{name: "azure"}}) +// exercises the guard that actually enforces rejection of an +// unrecognized-type provider in production: the provs-map type switch in +// resolveSKULookupProviderFromMap. +func TestHandleGetPriceBySKU_WrongProvider_TypeSwitchDefault(t *testing.T) { + h := tools.New(map[string]tools.Provider{"faketestcloud": &mockProvider{name: "faketestcloud"}}) resp := callGetPriceBySKU(t, h, tools.GetPriceBySKUInput{ - Provider: "azure", + Provider: "faketestcloud", SKU: "BoxUsage:r6id.24xlarge", Regions: []string{"us-east-1"}, }) @@ -872,6 +879,48 @@ func TestHandleGetPriceBySKU_GCPHappyPath(t *testing.T) { } } +// -------------------------------------------------------------------------- +// Azure raw-SKU lookup (SKU-lookup-tool wiring, third provider alongside AWS/GCP) +// -------------------------------------------------------------------------- + +// TestHandleGetPriceBySKU_AzureHappyPath verifies an Azure Retail Prices API +// meterId resolves through HandleGetPriceBySKU against a real +// *azureprovider.Provider (proving resolveSKULookupProviderFromMap's +// type-switch reaches its *azureprovider.Provider case, not just the +// AWS/GCP cases), and that the AWS-only usage_type_prefix/usage_type_suffix +// fields are omitted entirely from the response for an Azure result — the +// Azure counterpart to TestHandleGetPriceBySKU_GCPHappyPath above. +func TestHandleGetPriceBySKU_AzureHappyPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(azureSKUFixtureJSON( + "00000000-0000-0000-0000-000000000000", "eastus", "D4s v3", "Virtual Machines Dsv3 Series", "Virtual Machines", 0.192))) + })) + defer server.Close() + realAzure := newAzureSKUTestProvider(server) + h := tools.New(map[string]tools.Provider{"azure": realAzure}) + + resp := callGetPriceBySKU(t, h, tools.GetPriceBySKUInput{ + Provider: "azure", + SKU: "00000000-0000-0000-0000-000000000000", + Regions: []string{"eastus"}, + }) + + if resp["error"] != nil { + t.Fatalf("expected no error, got: %v", resp) + } + if resp["cheapest_region"] != "eastus" { + t.Errorf("expected cheapest_region eastus, got %v", resp["cheapest_region"]) + } + if _, ok := resp["usage_type_prefix"]; ok { + t.Errorf("expected usage_type_prefix to be omitted for an Azure result, got present: %v", resp["usage_type_prefix"]) + } + if _, ok := resp["usage_type_suffix"]; ok { + t.Errorf("expected usage_type_suffix to be omitted for an Azure result, got present: %v", resp["usage_type_suffix"]) + } +} + // Note: resolveSKUPriceEntry's generic (non-*SKULookupError) upstream_failure // branch — which now also echoes back "regions": in.Regions as part of this // fix — is not exercised by a test here. Every current top-level error diff --git a/opencloudcosts-go/schemas/tools-snapshot.json b/opencloudcosts-go/schemas/tools-snapshot.json index b7e271d..5af61db 100644 --- a/opencloudcosts-go/schemas/tools-snapshot.json +++ b/opencloudcosts-go/schemas/tools-snapshot.json @@ -106,7 +106,7 @@ }, { "name": "get_price_by_sku", - "description": "\n Resolve a raw AWS usage-type/SKU string — exactly as it appears in a Cost & Usage Report\n (CUR) export, e.g. \"CAN1-BoxUsage:r5a.8xlarge\" — or a raw GCP Cloud Billing Catalog skuId\n string (provider=\"gcp\") to a price, across one or more regions.\n\n Use this instead of get_price/compare_prices when you have a raw billing export line item\n (a \"UsageType\"/\"SKU\" column value, or a GCP skuId) and need to reconcile it against current\n public pricing, rather than starting from a known resource_type/domain spec. This tool strips the\n region-prefix token from the usage-type string (e.g. \"CAN1-\", \"EU-\", or no prefix at all\n for us-east-1) to get a region-independent suffix, then matches that suffix against each\n target region's pricing catalog. (This prefix-stripping step is AWS-only; see the GCP\n paragraph below for how provider=\"gcp\" resolves instead.)\n\n If service is omitted, the AWS servicecode is inferred from the usage-type pattern (e.g.\n \"BoxUsage:\" implies AmazonEC2, \"LCUUsage\" implies AWSELB) — service_source in the response\n indicates \"explicit\" or \"inferred\". If a supplied service hint finds no match but the\n inferred servicecode does (real CUR data isn't always internally consistent — e.g. data-\n transfer usage types sometimes appear against an \"AmazonEC2\" AWS Product column but are\n actually billed under AWSDataTransfer), the tool falls back to the inferred match and flags\n service_mismatch on that region's result rather than reporting no match.\n\n Some usage-type suffixes are shared by multiple distinct billable products (e.g. ELB's\n \"LCUUsage\" suffix matches Application/Network/Gateway load balancer pricing alike; RDS's\n \"InstanceUsage:\" suffix matches every database engine on that instance type). When\n that happens the affected region is reported under ambiguous_in (NOT in\n all_regions_sorted/cheapest_price/most_expensive_price — an ambiguous multi-product match\n is never silently resolved to \"cheapest\"), with every candidate row listed under\n alternate_matches. Pass operation and/or product_family — the same columns a CUR export\n carries alongside the usage-type/SKU column — to resolve it: e.g. for an Application Load\n Balancer LCU usage-type, product_family=\"Load Balancer-Application\" picks the correct row\n out of the Application/Network/Gateway alternatives.\n\n Regions with no catalog entry for the resolved suffix are reported in no_mapping_in\n (checked, not found) — this is distinct from errors_in (the catalog fetch itself failed)\n and from ambiguous_in (matched, but more than one product row and not yet resolved).\n Known limitation: compound inter-region/wavelength data-transfer SKUs with two region-\n shaped tokens (e.g. \"USE1WL1ATL1-CAN1-AWS-Out-Bytes\") are not fully resolved by the\n single-prefix-strip model; these produce a warning rather than a silently wrong match.\n\n For provider=\"gcp\": sku is a Cloud Billing Catalog skuId (e.g. \"D041-9EFB-5FA5\"), matched\n exactly (no prefix-stripping) against the service hint's catalog if given, or every\n onboarded service's catalog if service is omitted. operation/product_family hints are AWS-only and\n ignored for GCP — a matched skuId is unambiguous, so ambiguous_in does not apply; instead\n some GCP SKUs are usage-volume tiered (result entries carry \"tiered\": true plus an\n \"all_tier_rates\" array; the entry's own price_per_unit is the lowest tier's rate). GCP's\n service_source is \"explicit\" (service given) or \"scanned_all\" (no hint — every onboarded\n service's catalog is searched) rather than AWS's \"inferred\".\n\n Args:\n provider: Cloud provider — \"aws\" (raw usage-type SKUs) or \"gcp\" (raw Cloud Billing\n Catalog skuId strings). Defaults to \"aws\".\n sku: The raw usage-type/SKU string exactly as it appears in the CUR export (AWS), or\n the raw Cloud Billing Catalog skuId string (GCP).\n service: Optional service hint. For AWS, a servicecode (e.g. \"AmazonEC2\", \"AWSELB\",\n \"AmazonRDS\", \"AmazonDynamoDB\", \"AmazonElastiCache\", \"AWSDataTransfer\") — if\n omitted, it is inferred from the usage-type pattern. For GCP, one of the\n onboarded service names (e.g. \"compute\", \"gcs\", \"cloudsql\", \"gke\",\n \"memorystore\", \"kms\", \"dns\", \"firestore\", \"pubsub\", \"vertex\", \"bigquery\",\n \"monitoring\", \"armor\") — if omitted, every onboarded service is searched.\n regions: List of region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"] (AWS) or\n [\"us-central1\"] (GCP). Required, max 30.\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n operation: Optional AWS-only disambiguating hint — the AWS product \"operation\"\n attribute (e.g. \"CreateDBInstance:0021\" identifies Aurora PostgreSQL among\n RDS engines on the same instance type), matched case-insensitively. Use this\n when a region comes back in ambiguous_in. Ignored for provider=\"gcp\".\n product_family: Optional AWS-only disambiguating hint — the AWS top-level\n \"productFamily\" (e.g. \"Load Balancer-Application\" for an ALB vs\n NLB/GLB), matched case-insensitively. Use this when a region comes back\n in ambiguous_in. Ignored for provider=\"gcp\".\n\n Examples:\n {\"sku\": \"CAN1-BoxUsage:r5a.8xlarge\", \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n {\"sku\": \"CAN1-AWS-Out-Bytes\", \"service\": \"AmazonEC2\", \"regions\": [\"ca-central-1\"]}\n {\"sku\": \"CAN1-LCUUsage\", \"service\": \"AWSELB\", \"product_family\": \"Load Balancer-Application\",\n \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n {\"provider\": \"gcp\", \"sku\": \"D041-9EFB-5FA5\", \"regions\": [\"us-central1\", \"europe-west1\"]}\n ", + "description": "\n Resolve a raw AWS usage-type/SKU string — exactly as it appears in a Cost & Usage Report\n (CUR) export, e.g. \"CAN1-BoxUsage:r5a.8xlarge\" — a raw GCP Cloud Billing Catalog skuId\n string (provider=\"gcp\"), or a raw Azure Retail Prices API meterId string (provider=\"azure\",\n a GUID, e.g. \"00000000-0000-0000-0000-000000000000\") to a price, across one or more\n regions.\n\n Use this instead of get_price/compare_prices when you have a raw billing export line item\n (a \"UsageType\"/\"SKU\" column value, or a GCP skuId) and need to reconcile it against current\n public pricing, rather than starting from a known resource_type/domain spec. This tool strips the\n region-prefix token from the usage-type string (e.g. \"CAN1-\", \"EU-\", or no prefix at all\n for us-east-1) to get a region-independent suffix, then matches that suffix against each\n target region's pricing catalog. (This prefix-stripping step is AWS-only; see the GCP and\n Azure paragraphs below for how provider=\"gcp\"/provider=\"azure\" resolve instead.)\n\n If service is omitted, the AWS servicecode is inferred from the usage-type pattern (e.g.\n \"BoxUsage:\" implies AmazonEC2, \"LCUUsage\" implies AWSELB) — service_source in the response\n indicates \"explicit\" or \"inferred\". If a supplied service hint finds no match but the\n inferred servicecode does (real CUR data isn't always internally consistent — e.g. data-\n transfer usage types sometimes appear against an \"AmazonEC2\" AWS Product column but are\n actually billed under AWSDataTransfer), the tool falls back to the inferred match and flags\n service_mismatch on that region's result rather than reporting no match.\n\n Some usage-type suffixes are shared by multiple distinct billable products (e.g. ELB's\n \"LCUUsage\" suffix matches Application/Network/Gateway load balancer pricing alike; RDS's\n \"InstanceUsage:\" suffix matches every database engine on that instance type). When\n that happens the affected region is reported under ambiguous_in (NOT in\n all_regions_sorted/cheapest_price/most_expensive_price — an ambiguous multi-product match\n is never silently resolved to \"cheapest\"), with every candidate row listed under\n alternate_matches. Pass operation and/or product_family — the same columns a CUR export\n carries alongside the usage-type/SKU column — to resolve it: e.g. for an Application Load\n Balancer LCU usage-type, product_family=\"Load Balancer-Application\" picks the correct row\n out of the Application/Network/Gateway alternatives.\n\n Regions with no catalog entry for the resolved suffix are reported in no_mapping_in\n (checked, not found) — this is distinct from errors_in (the catalog fetch itself failed)\n and from ambiguous_in (matched, but more than one product row and not yet resolved).\n Known limitation: compound inter-region/wavelength data-transfer SKUs with two region-\n shaped tokens (e.g. \"USE1WL1ATL1-CAN1-AWS-Out-Bytes\") are not fully resolved by the\n single-prefix-strip model; these produce a warning rather than a silently wrong match.\n\n For provider=\"gcp\": sku is a Cloud Billing Catalog skuId (e.g. \"D041-9EFB-5FA5\"), matched\n exactly (no prefix-stripping) against the service hint's catalog if given, or every\n onboarded service's catalog if service is omitted. operation/product_family hints are AWS-only and\n ignored for GCP — a matched skuId is unambiguous, so ambiguous_in does not apply; instead\n some GCP SKUs are usage-volume tiered (result entries carry \"tiered\": true plus an\n \"all_tier_rates\" array; the entry's own price_per_unit is the lowest tier's rate). GCP's\n service_source is \"explicit\" (service given) or \"scanned_all\" (no hint — every onboarded\n service's catalog is searched) rather than AWS's \"inferred\".\n\n For provider=\"azure\": sku is an Azure Retail Prices API meterId (a GUID), matched exactly\n (no prefix-stripping, like GCP) against the region's catalog. service is not used (the\n meterId itself is looked up directly) and operation is ignored — Azure has no equivalent\n hint. product_family carries AZURE-SPECIFIC meaning here, distinct from the AWS\n productFamily/GCP cases above: pass one of \"Consumption\", \"DevTestConsumption\", or\n \"Reservation\" to match against the row's type field (case-insensitively), OR pass the\n literal value \"spot\" to match against the row's meterName instead of type (Azure spot rows\n are Consumption-type rows whose meterName contains \"Spot\", not a distinct type value) —\n do not assume \"spot\" resolves the same way as the type-equality hints. Some Azure\n Reservation-type meterId matches span more than one genuinely distinct billable product and\n cannot be safely disambiguated by this tool even with a hint; those are reported under\n ambiguous_in rather than guessed.\n\n Args:\n provider: Cloud provider — \"aws\" (raw usage-type SKUs), \"gcp\" (raw Cloud Billing\n Catalog skuId strings), or \"azure\" (raw Retail Prices API meterId strings).\n Defaults to \"aws\".\n sku: The raw usage-type/SKU string exactly as it appears in the CUR export (AWS), the\n raw Cloud Billing Catalog skuId string (GCP), or the raw Retail Prices API meterId\n GUID string (Azure).\n service: Optional service hint. For AWS, a servicecode (e.g. \"AmazonEC2\", \"AWSELB\",\n \"AmazonRDS\", \"AmazonDynamoDB\", \"AmazonElastiCache\", \"AWSDataTransfer\") — if\n omitted, it is inferred from the usage-type pattern. For GCP, one of the\n onboarded service names (e.g. \"compute\", \"gcs\", \"cloudsql\", \"gke\",\n \"memorystore\", \"kms\", \"dns\", \"firestore\", \"pubsub\", \"vertex\", \"bigquery\",\n \"monitoring\", \"armor\") — if omitted, every onboarded service is searched.\n Unused for Azure (the meterId is looked up directly).\n regions: List of region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"] (AWS),\n [\"us-central1\"] (GCP), or [\"eastus\"] (Azure). Required, max 30.\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n operation: Optional AWS-only disambiguating hint — the AWS product \"operation\"\n attribute (e.g. \"CreateDBInstance:0021\" identifies Aurora PostgreSQL among\n RDS engines on the same instance type), matched case-insensitively. Use this\n when a region comes back in ambiguous_in. Ignored for provider=\"gcp\" or\n provider=\"azure\" (Azure has no equivalent hint).\n product_family: Optional disambiguating hint whose meaning is provider-specific. For\n AWS, the AWS top-level \"productFamily\" (e.g. \"Load Balancer-Application\"\n for an ALB vs NLB/GLB), matched case-insensitively. Ignored for\n provider=\"gcp\". For provider=\"azure\", pass \"Consumption\",\n \"DevTestConsumption\", or \"Reservation\" to match the row's type field, or\n the literal \"spot\" to match the row's meterName instead (NOT resolved\n the same way as the type values — see the Azure paragraph above). Use\n this when a region comes back in ambiguous_in.\n\n Examples:\n {\"sku\": \"CAN1-BoxUsage:r5a.8xlarge\", \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n {\"sku\": \"CAN1-AWS-Out-Bytes\", \"service\": \"AmazonEC2\", \"regions\": [\"ca-central-1\"]}\n {\"sku\": \"CAN1-LCUUsage\", \"service\": \"AWSELB\", \"product_family\": \"Load Balancer-Application\",\n \"regions\": [\"ca-central-1\", \"us-east-1\"]}\n {\"provider\": \"gcp\", \"sku\": \"D041-9EFB-5FA5\", \"regions\": [\"us-central1\", \"europe-west1\"]}\n {\"provider\": \"azure\", \"sku\": \"00000000-0000-0000-0000-000000000000\", \"regions\": [\"eastus\", \"westeurope\"]}\n ", "inputSchema": { "properties": { "baseline_region": { @@ -163,7 +163,7 @@ }, { "name": "get_prices_by_sku", - "description": "\n Batch form of get_price_by_sku: resolve many raw AWS usage-type/SKU strings — each exactly\n as it appears in a Cost & Usage Report (CUR) export — or many raw GCP Cloud Billing Catalog\n skuId strings (provider=\"gcp\") — against the same set of target regions in one call.\n\n Use this to reconcile many CUR line items (or GCP skuIds) at once instead of issuing one\n get_price_by_sku call per SKU. Each sku is resolved independently via the same logic\n get_price_by_sku uses, so per-region ambiguous_in/no_mapping_in/errors_in bucketing (AWS),\n tiered/all_tier_rates (GCP), and baseline_region deltas all apply per sku exactly as they\n would in a standalone get_price_by_sku call — this tool only adds the batching and\n aggregation layer on top.\n\n service/operation/product_family hints are not supported here (they are inherently\n per-sku, and different SKUs in a batch usually resolve to different services) — for AWS the\n servicecode is inferred per sku from its usage-type pattern; for GCP every onboarded\n service's catalog is searched per sku. If a particular sku needs a hint to resolve an\n ambiguous_in entry (AWS) or to narrow the search (GCP), follow up with a single\n get_price_by_sku call for that sku, passing service and, for AWS, operation/product_family.\n\n Each successfully-processed sku appears in \"results\", in the same order as the input skus\n list (NOT re-sorted by price — distinct SKUs commonly price in different units, e.g.\n per-hour vs per-GB vs per-request, that are not meaningfully comparable). A sku that fails\n outright (e.g. an empty string, or a usage-type pattern no service could be inferred for)\n is instead reported in the top-level \"errors\" map, keyed by that sku string, with\n message/status/retryable fields mirroring get_prices_batch's per-item error shape.\n\n Args:\n provider: Cloud provider — \"aws\" (raw usage-type SKUs) or \"gcp\" (raw Cloud Billing\n Catalog skuId strings). Defaults to \"aws\".\n skus: List of raw usage-type/SKU strings (AWS) or skuId strings (GCP). Required, max 25.\n regions: List of region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"] (AWS) or\n [\"us-central1\"] (GCP). Required, max 30 (applies to every sku).\n baseline_region: Optional region for delta comparison, applied to every sku,\n e.g. \"us-east-1\".\n\n Examples:\n {\"skus\": [\"CAN1-BoxUsage:r5a.8xlarge\", \"USW2-BoxUsage:m5.large\"], \"regions\": [\"us-east-1\", \"ca-central-1\"]}\n {\"provider\": \"gcp\", \"skus\": [\"D041-9EFB-5FA5\"], \"regions\": [\"us-central1\", \"europe-west1\"]}\n ", + "description": "\n Batch form of get_price_by_sku: resolve many raw AWS usage-type/SKU strings — each exactly\n as it appears in a Cost & Usage Report (CUR) export — many raw GCP Cloud Billing Catalog\n skuId strings (provider=\"gcp\"), or many raw Azure Retail Prices API meterId strings\n (provider=\"azure\") — against the same set of target regions in one call.\n\n Use this to reconcile many CUR line items (or GCP skuIds/Azure meterIds) at once instead\n of issuing one get_price_by_sku call per SKU. Each sku is resolved independently via the\n same logic get_price_by_sku uses, so per-region ambiguous_in/no_mapping_in/errors_in\n bucketing (AWS and Azure), tiered/all_tier_rates (GCP), and baseline_region deltas all\n apply per sku exactly as they would in a standalone get_price_by_sku call — this tool only\n adds the batching and aggregation layer on top.\n\n service/operation/product_family hints are not supported here (they are inherently\n per-sku, and different SKUs in a batch usually resolve to different services) — for AWS the\n servicecode is inferred per sku from its usage-type pattern; for GCP every onboarded\n service's catalog is searched per sku; for Azure the meterId is looked up directly and any\n Reservation-type collision that get_price_by_sku's product_family hint could otherwise\n resolve is instead reported ambiguous. If a particular sku needs a hint to resolve an\n ambiguous_in entry (AWS or Azure) or to narrow the search (GCP), follow up with a single\n get_price_by_sku call for that sku, passing service and, for AWS/Azure, operation/\n product_family.\n\n Each successfully-processed sku appears in \"results\", in the same order as the input skus\n list (NOT re-sorted by price — distinct SKUs commonly price in different units, e.g.\n per-hour vs per-GB vs per-request, that are not meaningfully comparable). A sku that fails\n outright (e.g. an empty string, or a usage-type pattern no service could be inferred for)\n is instead reported in the top-level \"errors\" map, keyed by that sku string, with\n message/status/retryable fields mirroring get_prices_batch's per-item error shape.\n\n Args:\n provider: Cloud provider — \"aws\" (raw usage-type SKUs), \"gcp\" (raw Cloud Billing\n Catalog skuId strings), or \"azure\" (raw Retail Prices API meterId strings).\n Defaults to \"aws\".\n skus: List of raw usage-type/SKU strings (AWS), skuId strings (GCP), or meterId GUID\n strings (Azure). Required, max 25.\n regions: List of region codes to check, e.g. [\"us-east-1\", \"eu-west-1\"] (AWS),\n [\"us-central1\"] (GCP), or [\"eastus\"] (Azure). Required, max 30 (applies to\n every sku).\n baseline_region: Optional region for delta comparison, applied to every sku,\n e.g. \"us-east-1\".\n\n Examples:\n {\"skus\": [\"CAN1-BoxUsage:r5a.8xlarge\", \"USW2-BoxUsage:m5.large\"], \"regions\": [\"us-east-1\", \"ca-central-1\"]}\n {\"provider\": \"gcp\", \"skus\": [\"D041-9EFB-5FA5\"], \"regions\": [\"us-central1\", \"europe-west1\"]}\n {\"provider\": \"azure\", \"skus\": [\"00000000-0000-0000-0000-000000000000\"], \"regions\": [\"eastus\", \"westeurope\"]}\n ", "inputSchema": { "properties": { "provider": { @@ -532,7 +532,7 @@ }, { "name": "estimate_bom", - "description": "\n Use this tool for total infrastructure cost, TCO, monthly spend for a multi-resource\n stack, or cost comparison between architectures.\n\n Handles compute + storage + database + AI together in a single call — do NOT call\n get_price individually for multi-resource questions; use this tool instead.\n\n Returns per-item and total monthly/annual costs with real public pricing data,\n plus a not_included list of supplementary costs (egress, load balancers, monitoring).\n These are SUPPLEMENTARY — only price them if the user asked for TCO; for most\n questions just note 'additional costs may apply'.\n\n Each item should be a PricingSpec dict PLUS a quantity field:\n - provider: \"aws\" | \"gcp\" | \"azure\"\n - domain: \"compute\" | \"storage\" | \"database\" | \"ai\" | ...\n - region: region code\n - quantity: number of units (default 1)\n - hours_per_month: hours/month for compute (default 730 = always-on)\n - description: optional label for this line item\n Plus domain-specific fields (see get_price or describe_catalog for details).\n\n An item may instead be a raw-SKU dict: {\"sku\": \"...\",\n \"region\": \"us-east-1\", \"quantity\": 3} — the same raw CUR usage-type/SKU string (provider\n \"aws\", default) or GCP Cloud Billing Catalog skuId string (provider \"gcp\") get_price_by_sku\n resolves, optionally with service/operation/product_family hints to disambiguate\n (operation/product_family are AWS-only; ignored for provider \"gcp\"). A GCP SKU with\n usage-volume tiers is costed at the tier matching this item's quantity.\n\n Examples:\n Compute + database + storage on AWS:\n [\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\", \"quantity\": 3},\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r6g.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"},\n {\"provider\": \"aws\", \"domain\": \"storage\", \"storage_type\": \"gp3\", \"size_gb\": 500, \"region\": \"us-east-1\"}\n ]\n\n Mixed cloud:\n [\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"quantity\": 2},\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\", \"quantity\": 1}\n ]\n ", + "description": "\n Use this tool for total infrastructure cost, TCO, monthly spend for a multi-resource\n stack, or cost comparison between architectures.\n\n Handles compute + storage + database + AI together in a single call — do NOT call\n get_price individually for multi-resource questions; use this tool instead.\n\n Returns per-item and total monthly/annual costs with real public pricing data,\n plus a not_included list of supplementary costs (egress, load balancers, monitoring).\n These are SUPPLEMENTARY — only price them if the user asked for TCO; for most\n questions just note 'additional costs may apply'.\n\n Each item should be a PricingSpec dict PLUS a quantity field:\n - provider: \"aws\" | \"gcp\" | \"azure\"\n - domain: \"compute\" | \"storage\" | \"database\" | \"ai\" | ...\n - region: region code\n - quantity: number of units (default 1)\n - hours_per_month: hours/month for compute (default 730 = always-on)\n - description: optional label for this line item\n Plus domain-specific fields (see get_price or describe_catalog for details).\n\n An item may instead be a raw-SKU dict: {\"sku\": \"...\",\n \"region\": \"us-east-1\", \"quantity\": 3} — the same raw CUR usage-type/SKU string (provider\n \"aws\", default), GCP Cloud Billing Catalog skuId string (provider \"gcp\"), or Azure Retail\n Prices API meterId string (provider \"azure\") get_price_by_sku resolves, optionally with\n service/operation/product_family hints to disambiguate (operation is AWS-only, ignored for\n provider \"gcp\"/\"azure\"; product_family is AWS-only for the productFamily-matching behavior\n described in get_price_by_sku, but carries different Azure-specific meaning — see\n get_price_by_sku — for provider \"azure\", and is ignored for provider \"gcp\"). A GCP SKU with\n usage-volume tiers is costed at the tier matching this item's quantity.\n\n Examples:\n Compute + database + storage on AWS:\n [\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\", \"quantity\": 3},\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r6g.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"},\n {\"provider\": \"aws\", \"domain\": \"storage\", \"storage_type\": \"gp3\", \"size_gb\": 500, \"region\": \"us-east-1\"}\n ]\n\n Mixed cloud:\n [\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"quantity\": 2},\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\", \"quantity\": 1}\n ]\n ", "inputSchema": { "properties": { "items": { @@ -715,7 +715,7 @@ }, { "name": "compare_bom_regions", - "description": "\n Compare a Bill of Materials' total monthly cost across multiple regions.\n\n v1 scope: PricingSpec-dict items are AWS-only. Each item is an open PricingSpec dict, same\n shape as estimate_bom's items (provider, domain, resource_type/region/etc, plus\n quantity/hours_per_month/size_gb/description) — or a raw-SKU dict (sku, region, plus\n optional service/operation/product_family) for a CUR usage-type/SKU string (provider=\"aws\"\n or omitted) or a GCP Cloud Billing Catalog skuId string (provider=\"gcp\"; operation/\n product_family are ignored for GCP). The region field on each item is overridden per\n comparison — pass any region in the item dicts. A region's region_name is only populated\n from the region-code display maps when every resolvable item in the call shares one\n provider; a mixed-provider call (e.g. an AWS item and a GCP item together) falls back to\n the bare region code instead of guessing whose naming applies.\n Weighting and a providers filter are not supported yet. Unsupported items\n (a PricingSpec-dict item naming a non-AWS provider, or a raw-SKU item naming a provider\n other than aws/gcp) are reported once under \"not_supported\" rather than guessed or dropped\n silently; full GCP/Azure PricingSpec-dict support is tracked separately.\n\n Returns regions[] sorted cheapest-first, each with total_monthly, the\n resolved line_items, and any per-item errors. Optionally shows delta vs\n a baseline region.\n\n Args:\n items: List of PricingSpec dicts (same shape as estimate_bom, AWS-only) — or\n raw-SKU dicts (sku, region, plus optional service/operation/\n product_family), provider \"aws\" (default) or \"gcp\". See estimate_bom for full\n item format.\n regions: List of region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\"].\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n ", + "description": "\n Compare a Bill of Materials' total monthly cost across multiple regions.\n\n v1 scope: PricingSpec-dict items are AWS-only. Each item is an open PricingSpec dict, same\n shape as estimate_bom's items (provider, domain, resource_type/region/etc, plus\n quantity/hours_per_month/size_gb/description) — or a raw-SKU dict (sku, region, plus\n optional service/operation/product_family) for a CUR usage-type/SKU string (provider=\"aws\"\n or omitted), a GCP Cloud Billing Catalog skuId string (provider=\"gcp\"; operation/\n product_family are ignored for GCP), or an Azure Retail Prices API meterId string\n (provider=\"azure\"; operation is ignored, product_family has Azure-specific meaning — see\n get_price_by_sku). The region field on each item is overridden per comparison — pass any\n region in the item dicts. A region's region_name is only populated from the region-code\n display maps when every resolvable item in the call shares one provider; a mixed-provider\n call (e.g. an AWS item and a GCP item together) falls back to the bare region code instead\n of guessing whose naming applies.\n Weighting and a providers filter are not supported yet. Unsupported items\n (a PricingSpec-dict item naming a non-AWS provider, or a raw-SKU item naming a provider\n other than aws/gcp/azure) are reported once under \"not_supported\" rather than guessed or\n dropped silently; full GCP/Azure PricingSpec-dict support is tracked separately.\n\n Returns regions[] sorted cheapest-first, each with total_monthly, the\n resolved line_items, and any per-item errors. Optionally shows delta vs\n a baseline region.\n\n Args:\n items: List of PricingSpec dicts (same shape as estimate_bom, AWS-only) — or\n raw-SKU dicts (sku, region, plus optional service/operation/\n product_family), provider \"aws\" (default), \"gcp\", or \"azure\". See estimate_bom\n for full item format.\n regions: List of region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\"].\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n ", "inputSchema": { "properties": { "baseline_region": { From 7ebc555ab5e3a1cfd01fdf24b60da442a44052a2 Mon Sep 17 00:00:00 2001 From: x7even <38901965+x7even@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:59:13 +0000 Subject: [PATCH 6/9] fix(server): populate MCP structuredContent on every tool response Every tool declares an OutputSchema, but the shared jsonText response helper only ever set Content (unstructured text), never StructuredContent - a pre-existing gap since the original Go server rewrite. Strict MCP clients (the official Python SDK, used by the local test harness) reject every tool call outright when a schema is declared but structuredContent is absent, which surfaced only now under harness testing. Also defaults two nil []string fields (get_price_by_sku/get_prices_by_sku's no_mapping_in[].attempted_services on Azure's no-mapping path, and the upstream_failure branch's regions when the caller omits regions) to empty arrays before they reach the response map - both marshal to JSON null otherwise, which fails schema validation since both fields are declared as non-nullable arrays. --- opencloudcosts-go/internal/server/server.go | 6 +++-- opencloudcosts-go/internal/tools/lookup.go | 15 ++++++++--- .../internal/tools/sku_lookup.go | 27 +++++++++++++++++-- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/opencloudcosts-go/internal/server/server.go b/opencloudcosts-go/internal/server/server.go index 8d4fa4d..2884445 100644 --- a/opencloudcosts-go/internal/server/server.go +++ b/opencloudcosts-go/internal/server/server.go @@ -122,14 +122,16 @@ func (s *AppServer) callTool( "panic", fmt.Sprintf("%v", r), "latency_ms", time.Since(start).Milliseconds(), ) - b, _ := json.Marshal(map[string]any{ + fields := map[string]any{ "error": "internal_error", "message": "An unexpected error occurred. Please try again.", - }) + } + b, _ := json.Marshal(fields) res = &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: string(b)}, }, + StructuredContent: fields, } retErr = nil } diff --git a/opencloudcosts-go/internal/tools/lookup.go b/opencloudcosts-go/internal/tools/lookup.go index ad7cc43..c777580 100644 --- a/opencloudcosts-go/internal/tools/lookup.go +++ b/opencloudcosts-go/internal/tools/lookup.go @@ -75,20 +75,27 @@ func (h *Handler) provider(name string) Provider { // -------------------------------------------------------------------------- // jsonText returns a *mcp.CallToolResult containing a single TextContent block -// with the JSON-serialised value of v. If marshalling fails, a structured -// {"error": "internal_error", ...} object is returned instead. +// with the JSON-serialised value of v, and StructuredContent set to v itself +// so it matches the tool's declared output schema on the wire. If marshalling +// fails, a structured {"error": "internal_error", ...} object is returned +// instead (and used for StructuredContent too, since v could not be +// serialised). func jsonText(v any) *mcp.CallToolResult { b, err := json.Marshal(v) + structured := v if err != nil { - b, _ = json.Marshal(map[string]any{ + fallback := map[string]any{ "error": "internal_error", "message": "failed to serialise response", - }) + } + b, _ = json.Marshal(fallback) + structured = fallback } return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: string(b)}, }, + StructuredContent: structured, } } diff --git a/opencloudcosts-go/internal/tools/sku_lookup.go b/opencloudcosts-go/internal/tools/sku_lookup.go index 5c4c165..fc81515 100644 --- a/opencloudcosts-go/internal/tools/sku_lookup.go +++ b/opencloudcosts-go/internal/tools/sku_lookup.go @@ -224,11 +224,23 @@ func (h *Handler) resolveSKUPriceEntry( "message": skuErr.Message, } } + // regions is schema-declared as a plain (non-nullable) array (see + // schemaGetPriceBySKUOutput's top-level "regions" property in + // server.go). HandleGetPriceBySKU (unlike the batch handler) does not + // length-check in.Regions before calling resolveSKUPriceEntry, so a + // caller who omits regions entirely reaches here with in.Regions nil + // on any non-SKULookupError failure (e.g. context cancellation) — + // default defensively here for the same reason attempted_services is + // defaulted above. + regions := in.Regions + if regions == nil { + regions = []string{} + } return map[string]any{ "error": "upstream_failure", "message": "SKU lookup failed. Try again shortly.", "retryable": true, - "regions": in.Regions, + "regions": regions, } } @@ -305,9 +317,20 @@ func (h *Handler) resolveSKUPriceEntry( } ambiguousRegions = append(ambiguousRegions, ar) case skuResultNoMapping: + // attempted_services is schema-declared as a plain (non-nullable) + // array (see server.go's no_mapping_in schema). rr.AttemptedServices + // is nil, not just empty, on this path for Azure (azure_sku_lookup.go + // never populates it before setting NoMapping) — left as-is, that nil + // marshals to JSON null and a strict client (the official Python mcp + // SDK) rejects the whole response. AWS/GCP always populate it + // non-nil, but default defensively here regardless of provider. + attemptedServices := rr.AttemptedServices + if attemptedServices == nil { + attemptedServices = []string{} + } noMapping = append(noMapping, map[string]any{ "region": rr.Region, - "attempted_services": rr.AttemptedServices, + "attempted_services": attemptedServices, }) case skuResultError: erroredRegions = append(erroredRegions, map[string]any{ From 0c0235af192015cd464beca855b4e0a3262eafd8 Mon Sep 17 00:00:00 2001 From: x7even <38901965+x7even@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:31:51 +0000 Subject: [PATCH 7/9] test(harness): add 71 raw-SKU-lookup coverage tests across AWS/GCP/Azure Covers the 7 gaps flagged in code review: AWS raw-SKU success, GCP/Azure no-mapping, ambiguous match, GCP/Azure tiered rates, batch mixed outcomes, raw-SKU BoM line items, and protocol edge cases (missing region, empty batch, unsupported provider, oversized batch, blank SKU). Adds rsku_manifest.json alongside TEST_PROMPTS since the harness has no per-test expected-outcome schema; the manifest carries expected_outcome per test ID for post-run grading. --- local-test-harness/rsku_manifest.json | 641 ++++++++++++++++++++++++++ local-test-harness/run_tests.py | 420 +++++++++++++++++ 2 files changed, 1061 insertions(+) create mode 100644 local-test-harness/rsku_manifest.json diff --git a/local-test-harness/rsku_manifest.json b/local-test-harness/rsku_manifest.json new file mode 100644 index 0000000..597c8f9 --- /dev/null +++ b/local-test-harness/rsku_manifest.json @@ -0,0 +1,641 @@ +[ + { + "id": "RSKU_AWS_OK1", + "prompt": "Our AWS Cost and Usage Report has a line item with usage type \"BoxUsage:c8g.xlarge\" billed in us-east-1. What's the on-demand hourly rate for that usage type so I can check it against what we were actually charged?", + "category": "aws-raw-sku-success", + "cloud": "aws", + "expected_outcome": "matched", + "sku_used": "BoxUsage:c8g.xlarge", + "notes": "Live-verified against running server: us-east-1 -> $0.15952/hr (c8g.xlarge, AWS Graviton4 compute-optimized, service inferred as AmazonEC2)." + }, + { + "id": "RSKU_AWS_OK2", + "prompt": "I'm reconciling our EC2 bill and one CUR row shows usage type BoxUsage:m6a.8xlarge in us-east-1. Can you pull the current public on-demand rate for that exact usage type and tell me if it lines up with $1.3824/hr?", + "category": "aws-raw-sku-success", + "cloud": "aws", + "expected_outcome": "matched", + "sku_used": "BoxUsage:m6a.8xlarge", + "notes": "Live-verified against running server: us-east-1 -> $1.3824/hr (m6a.8xlarge)." + }, + { + "id": "RSKU_AWS_OK3", + "prompt": "In our billing export there's a usage type of BoxUsage:c7i.2xlarge running in us-east-1. Can you give me both the hourly rate and what that works out to per month if it runs 24/7?", + "category": "aws-raw-sku-success", + "cloud": "aws", + "expected_outcome": "matched", + "sku_used": "BoxUsage:c7i.2xlarge", + "notes": "Live-verified against running server: us-east-1 -> $0.357/hr (c7i.2xlarge), monthly_estimate $260.61/mo returned alongside hourly rate." + }, + { + "id": "RSKU_AZ_NF1", + "prompt": "Our Azure billing export has a line item with meter ID 00000000-0000-0000-0000-000000000000 in the eastus region, but I can't find a current rate for it anywhere. What does this meter ID actually correspond to, and what's the current price?", + "category": "azure-no-mapping", + "cloud": "azure", + "expected_outcome": "no_mapping", + "sku_used": "00000000-0000-0000-0000-000000000000", + "notes": "Well-formed but nonexistent GUID meterId, guaranteed absent from the Azure Retail Prices catalog. Exercises resolveAzureSKURegion's miss path, where AttemptedServices is never populated before NoMapping=true is set -- so the no_mapping_in entry should show attempted_services: [] and the response must rely on a defensive nil-guard elsewhere to render cleanly rather than panic/error. Live-verified against 127.0.0.1:8123: returns no_prices_found with no_mapping_in:[{attempted_services:[],region:eastus}]." + }, + { + "id": "RSKU_AZ_OK1", + "prompt": "I'm reconciling our Azure CUR-style usage export and one line shows meter ID 3da19ca3-6007-4a29-89ea-cab10c2010ed for the eastus region. What VM SKU is that, and what's the current hourly rate?", + "category": "azure-raw-sku-success", + "cloud": "azure", + "expected_outcome": "matched", + "sku_used": "3da19ca3-6007-4a29-89ea-cab10c2010ed", + "notes": "Live-verified against 127.0.0.1:8123: resolves to Standard_D4_v3/D4s v3 (Virtual Machines Dv3 Series, Compute family), eastus, $0.192000/per_hour ($140.16/mo), single unambiguous match in all_regions_sorted." + }, + { + "id": "RSKU_AWS_AMB1", + "prompt": "My AWS Cost and Usage Report has a line item with usage type just \"LCUUsage\" in us-east-1 — no BoxUsage prefix, and the export doesn't say which load balancer it's billing. What's the hourly rate for that?", + "category": "ambiguous-match", + "cloud": "aws", + "expected_outcome": "ambiguous", + "sku_used": "LCUUsage", + "notes": "Live-verified just now against http://127.0.0.1:8123/ with region us-east-1 and no hints: result=\"no_prices_found\", ambiguous_in has one entry with alternate_match_count=3 (ALB $0.008/LCU-hr, NLB $0.006/LCU-hr, GWLB $0.004/LCU-hr — operations LoadBalancing:Application/Network/Gateway), hint_status=\"no_hint_supplied\". Known/documented ELB LCUUsage-suffix collision." + }, + { + "id": "RSKU_AWS_AMB2", + "prompt": "I'm reconciling my AWS bill and see a CUR line item with usage type \"InstanceUsage:db.r6g.large\" in us-east-1 — that's my MySQL RDS instance, right? What's the hourly rate for it?", + "category": "ambiguous-match", + "cloud": "aws", + "expected_outcome": "ambiguous", + "sku_used": "InstanceUsage:db.r6g.large", + "notes": "Live-verified just now against http://127.0.0.1:8123/ with region us-east-1 and no hints: result=\"no_prices_found\", ambiguous_in has one entry with alternate_match_count=5 spanning PostgreSQL ($0.225/hr), Aurora PostgreSQL ($0.26/hr), Aurora MySQL ($0.26/hr), MariaDB ($0.215/hr), and MySQL ($0.215/hr) — all on db.r6g.large. Surprising because the usage-type string reads like a single-instance/single-engine SKU but actually spans every RDS engine on that instance class; the user's assumption \"that's my MySQL instance\" is exactly the trap." + }, + { + "id": "RSKU_GCP_AMB1", + "prompt": "My GCP billing export has a Cloud KMS line item with skuId \"1017-1BAF-7159\" — what's the rate for those HSM asymmetric key versions?", + "category": "ambiguous-match", + "cloud": "gcp", + "expected_outcome": "tiered", + "sku_used": "1017-1BAF-7159", + "notes": "GCP SKU sourced from code comments (internal/providers/gcp/gcp_kms.go lines ~65-68), not independently live-verified — OCC_GCP_API_KEY unavailable on the 127.0.0.1:8123 test box, so every GCP call there returns a not_configured auth error regardless of skuId. In a GCP-enabled harness environment this is documented as a single, unambiguous SKU within the KMS service catalog (EE2F-D110-890C) but with tiered pricing (tieredRates startUsageAmount 0, then a discounted rate starting at 2000 key versions) — expect a single match with all_tier_rates populated, not an ambiguous_in entry. This is the \"known/expected to just work\" contrast case for the pairing (it should NOT be ambiguous), even though the tool call is exercising the same raw-skuId code path as RSKU_GCP_AMB2." + }, + { + "id": "RSKU_GCP_AMB2", + "prompt": "There's a GCP Cloud KMS charge on my bill for skuId \"4A51-C764-8B93\", described as \"Active Single Tenant HSM key versions (above 15000)\" — what does that cost per month?", + "category": "ambiguous-match", + "cloud": "gcp", + "expected_outcome": "matched", + "sku_used": "4A51-C764-8B93", + "notes": "GCP SKU sourced from code comments (internal/providers/gcp/gcp_kms.go lines ~19-23), not independently live-verified — OCC_GCP_API_KEY unavailable on the 127.0.0.1:8123 test box, so every GCP call there returns a not_configured auth error regardless of skuId. gcp_kms.go explicitly documents this SKU as OUT OF SCOPE for its domain-specific KMS pricing helper (belongs to a flat-fee dedicated-instance product, not the per-version/per-operation model that file prices) — but get_price_by_sku bypasses domain logic and queries the raw Cloud Billing Catalog directly, so in a GCP-enabled environment it is expected to resolve as a single, unambiguous match (a genuinely distinct, real SKU ID that exists only in the KMS service catalog) even though the \"friendly\" KMS tool ignores it entirely. This is the surprising-it-works case; the main risk to this expectation (unverifiable here) is if the raw skuId string happened to collide with an unrelated SKU in another of the 13 scanned service catalogs, which would flip this to ambiguous instead — flagged as the one open uncertainty." + }, + { + "id": "RSKU_AZ_AMB1", + "prompt": "My Azure invoice has a Network Watcher connection-monitor charge in West US with meter ID \"ba2b4df6-e886-4cf2-9818-33f27d22b3cf\" — what's the per-unit rate for that?", + "category": "ambiguous-match", + "cloud": "azure", + "expected_outcome": "ambiguous", + "sku_used": "ba2b4df6-e886-4cf2-9818-33f27d22b3cf", + "notes": "Live-verified just now against http://127.0.0.1:8123/ with region westus and NO hints supplied: result=\"no_prices_found\", ambiguous_in has one entry with alternate_match_count=5 (prices $0.00, $0.30, $0.02, $0.10, $0.05 at different tierMinimumUnits — the tier-collision guard in azure_sku_lookup.go step 7 rejects this set because the retail-price sequence across tiers is not monotonic, and one row is even missing meterName/productName attributes entirely), hint_status reported as \"hint_ambiguous\" even with no hint. This requires no product_family hint from the calling agent to surface — the raw lookup is ambiguous by default, so the outcome doesn't depend on the LLM's tool-arg choices." + }, + { + "id": "RSKU_AZ_AMB2", + "prompt": "My Azure invoice has an Azure Database for MySQL Single Server (Gen5, General Purpose) compute charge in UK South with meter ID \"ace03b73-4864-4a8c-afcb-55ddf91e010e\" — what's the hourly compute rate for that vCore?", + "category": "ambiguous-match", + "cloud": "azure", + "expected_outcome": "matched", + "sku_used": "ace03b73-4864-4a8c-afcb-55ddf91e010e", + "notes": "Live-verified just now against http://127.0.0.1:8123/ with region uksouth and no hints: resolves cleanly to a single match, $0.1016/hr ($74.17/mo), isPrimaryMeterRegion=true, type=Consumption. Surprising/unexpected because the raw Azure Retail Prices API actually returns 3 rows for this exact meterId+region (two Consumption rows at isPrimaryMeterRegion=false/true and one Reservation row at $569/1yr, isPrimaryMeterRegion=false) — a meterId that looks messy (mixed primary-region flags, a bundled reservation row) but the documented step-2 dedup (keep only isPrimaryMeterRegion=true rows, resolved strictly before any type/hint narrowing) collapses it to exactly one row with zero ambiguity and zero hint needed. This is the same 'same meterId/region/differing isPrimaryMeterRegion' fixture shape azure_sku_lookup_test.go describes, found live rather than approximated." + }, + { + "id": "RSKU_GCP_TIER1", + "prompt": "Our GCP billing export has a Cloud KMS line item with SKU ID 77F8-D8AF-3CCE for Autokey key-versions. Right now we're under 100 key versions a month and it's showing $0. If our key-version count grows past 100 next quarter, does the per-unit rate actually kick in at that point, or does this SKU stay free no matter how much we use?", + "category": "tiered-rate", + "cloud": "gcp", + "expected_outcome": "tiered", + "sku_used": "77F8-D8AF-3CCE", + "notes": "GCP SKU sourced from code comments (internal/providers/gcp/gcp_kms.go), not independently live-verified — OCC_GCP_API_KEY unavailable. Per those comments: KMS Autokey key-versions SKU, $0.00 tier below 100 key-versions/month, paid rate above 100 — response should show tiered=true with all_tier_rates listing the $0 and paid tiers." + }, + { + "id": "RSKU_GCP_TIER2", + "prompt": "We're reconciling a Cloud KMS charge with SKU ID 1017-1BAF-7159 for HSM asymmetric key versions. Our HSM key usage is ramping up fast — is there a volume discount that kicks in once we cross 2000 key versions in a month, or does this SKU charge the same rate no matter how much we use?", + "category": "tiered-rate", + "cloud": "gcp", + "expected_outcome": "tiered", + "sku_used": "1017-1BAF-7159", + "notes": "GCP SKU sourced from code comments (internal/providers/gcp/gcp_kms.go), not independently live-verified — OCC_GCP_API_KEY unavailable. Per those comments (kmsHSMTierThreshold=2000): HSM asymmetric key-version SKU with a volume-discount tier boundary at 2000 key versions/month — response should show tiered=true with all_tier_rates listing the below/above-2000 tiers." + }, + { + "id": "RSKU_AZ_TIER1", + "prompt": "On our Azure invoice, meter ID 6bd64e8e-5cb9-49d3-893d-800c9b28dca3 shows up for standard outbound data transfer in southcentralus. Some months we push well past 10,000 GB of egress — does the per-GB rate step down once we hit higher volumes, or is this a single flat rate no matter how much we send?", + "category": "tiered-rate", + "cloud": "azure", + "expected_outcome": "ambiguous", + "sku_used": "6bd64e8e-5cb9-49d3-893d-800c9b28dca3", + "notes": "Live-verified: this meterId (Standard Data Transfer Out, Bandwidth - Routing Preference: Internet, southcentralus) genuinely has 5 real pricing tiers on the Azure Retail Prices API (tierMinimumUnits 0/100/10100/50100/150100 GB, rates $0/$0.08/$0.065/$0.06/$0.04 per GB). However, get_price_by_sku against the live 127.0.0.1:8123 server returns result:\"no_prices_found\" with all 5 tier rows listed under ambiguous_in/alternate_matches (hint_status:\"hint_ambiguous\"), NOT under tiered_rates/all_tier_rates. Confirmed in internal/tools/sku_lookup.go: the `tiered` flag is set only from GCP's rr.Tiered (documented GCP-only) — Azure's provider never sets it, so multi-tier Azure meters always surface as ambiguous alternate matches rather than a consolidated tiered response. Task requested expected_outcome=\"tiered\" for this slot but that is not achievable against current code; set to \"ambiguous\" to match the actual trace per the instruction to be factual, not aspirational." + }, + { + "id": "RSKU_AZ_TIER2", + "prompt": "We have meter ID 9995d93a-7d35-4d3f-9c69-7a7fea447ef4 on our Azure bill for data transfer out of mexicocentral. Our egress there is climbing past 50,000 GB some months — is there a lower per-GB rate once we cross that volume, or does this meter bill flat regardless of usage?", + "category": "tiered-rate", + "cloud": "azure", + "expected_outcome": "ambiguous", + "sku_used": "9995d93a-7d35-4d3f-9c69-7a7fea447ef4", + "notes": "Live-verified: this meterId (Standard Data Transfer Out, Rtn Preference: MGN, mexicocentral) genuinely has 6 real pricing tiers on the Azure Retail Prices API (tierMinimumUnits 0/100/10335/51295/153695/512095 GB, rates $0/$0.087/$0.083/$0.07/$0.05/$0.05 per GB). However, get_price_by_sku against the live 127.0.0.1:8123 server returns result:\"no_prices_found\" with all 6 tier rows listed under ambiguous_in/alternate_matches (hint_status:\"hint_ambiguous\"), NOT under tiered_rates/all_tier_rates. Confirmed in internal/tools/sku_lookup.go: the `tiered` flag is set only from GCP's rr.Tiered (documented GCP-only) — Azure's provider never sets it, so multi-tier Azure meters always surface as ambiguous alternate matches rather than a consolidated tiered response. Task requested expected_outcome=\"tiered\" for this slot but that is not achievable against current code; set to \"ambiguous\" to match the actual trace per the instruction to be factual, not aspirational." + }, + { + "id": "RSKU_AWS_BATCH1", + "prompt": "I'm reconciling our AWS Cost and Usage Report for the compute team and I've got a few EC2 usage-type line items I need current on-demand rates for, all in us-east-1: BoxUsage:c8g.xlarge and BoxUsage:m6a.8xlarge. Can you price both out for me in one go?", + "category": "batch-match", + "cloud": "aws", + "expected_outcome": "matched", + "sku_used": "BoxUsage:c8g.xlarge, BoxUsage:m6a.8xlarge", + "notes": "Both SKUs live-verified against 127.0.0.1:8123 today (c8g.xlarge $0.15952/hr, m6a.8xlarge $1.3824/hr, us-east-1). Expect both to land in results[] as resolved prices, no errors/no_mapping." + }, + { + "id": "RSKU_AWS_BATCH2", + "prompt": "Our finance team pulled these three EC2 usage-type codes off the billing export and wants a per-hour rate check for us-east-1: BoxUsage:c8g.xlarge, BoxUsage:c7i.2xlarge, and BoxUsage:m6a.8xlarge. Can you pull current on-demand pricing for all three at once?", + "category": "batch-match", + "cloud": "aws", + "expected_outcome": "matched", + "sku_used": "BoxUsage:c8g.xlarge, BoxUsage:c7i.2xlarge, BoxUsage:m6a.8xlarge", + "notes": "All three SKUs live-verified against 127.0.0.1:8123 today (us-east-1). Expect all three in results[] as resolved prices, no errors/no_mapping." + }, + { + "id": "RSKU_AWS_BATCH3", + "prompt": "Quick sanity check on two line items from our AWS bill, both us-east-1: BoxUsage:c7i.2xlarge and BoxUsage:c8g.xlarge. What's the hourly rate on each?", + "category": "batch-match", + "cloud": "aws", + "expected_outcome": "matched", + "sku_used": "BoxUsage:c7i.2xlarge, BoxUsage:c8g.xlarge", + "notes": "Both SKUs live-verified against 127.0.0.1:8123 today (us-east-1). Expect both in results[] as resolved prices, no errors/no_mapping." + }, + { + "id": "RSKU_AWS_BATCH4", + "prompt": "I've got some odd EC2 usage-type strings in our Cost and Usage Report that I don't recognize from any instance family we run: BoxUsage:zz99.999xlarge and BoxUsage:nonexistent.type, both in us-east-1. Can you check what these actually cost, or flag if they're not real instance types?", + "category": "batch-not-found", + "cloud": "aws", + "expected_outcome": "no_mapping", + "sku_used": "BoxUsage:zz99.999xlarge, BoxUsage:nonexistent.type", + "notes": "Both are well-formed BoxUsage: usage-type strings (service infers to AmazonEC2) but the instance types don't exist; live-verified against 127.0.0.1:8123 today that both come back as no_prices_found/no_mapping_in within results[], not top-level errors." + }, + { + "id": "RSKU_AWS_BATCH5", + "prompt": "Two more mystery line items showed up on the export this month, both us-east-1: BoxUsage:zz99.999xlarge and CAN1-BoxUsage:totallyfake.4xlarge. Neither matches any instance type our team has ever provisioned — can you look them up and tell me what they resolve to?", + "category": "batch-not-found", + "cloud": "aws", + "expected_outcome": "no_mapping", + "sku_used": "BoxUsage:zz99.999xlarge, CAN1-BoxUsage:totallyfake.4xlarge", + "notes": "Both are well-formed usage-type strings (with/without region prefix) that infer to AmazonEC2 but reference nonexistent instance types; live-verified against 127.0.0.1:8123 today that both come back as no_prices_found/no_mapping_in within results[], not top-level errors." + }, + { + "id": "RSKU_AWS_BATCH6", + "prompt": "Trying to true up last month's compute spend and three of the usage-type codes on the report don't ring a bell: BoxUsage:nonexistent.type, CAN1-BoxUsage:totallyfake.4xlarge, and BoxUsage:zz99.999xlarge, all us-east-1. Can you check current pricing for these and let me know if any of them just aren't real SKUs?", + "category": "batch-not-found", + "cloud": "aws", + "expected_outcome": "no_mapping", + "sku_used": "BoxUsage:nonexistent.type, CAN1-BoxUsage:totallyfake.4xlarge, BoxUsage:zz99.999xlarge", + "notes": "All three are well-formed BoxUsage: usage-type strings that infer to AmazonEC2 but the instance types don't exist; live-verified against 127.0.0.1:8123 today that all three come back as no_prices_found/no_mapping_in within results[], not top-level errors." + }, + { + "id": "RSKU_AWS_BATCH7", + "prompt": "I copy-pasted a couple of lines from our billing export into a spreadsheet and I think the columns got scrambled — these don't look like real SKU codes to me: \"just some random billing text\" and \"12345-not-a-sku\". Can you check whether either of these actually prices out to anything on AWS?", + "category": "batch-invalid", + "cloud": "aws", + "expected_outcome": "invalid_error", + "sku_used": "just some random billing text, 12345-not-a-sku", + "notes": "Neither string matches any AWS usage-type pattern, so service can't be inferred; live-verified against 127.0.0.1:8123 today that both land in the top-level errors map with 'service is required — could not infer AWS servicecode' rather than a no_mapping result." + }, + { + "id": "RSKU_AWS_BATCH8", + "prompt": "Our export tool spit out some garbage-looking entries this run — \"###invalid###\" and \"12345-not-a-sku\" — instead of proper usage-type codes. Before I file a bug with the export vendor, can you confirm these really aren't valid AWS SKUs?", + "category": "batch-invalid", + "cloud": "aws", + "expected_outcome": "invalid_error", + "sku_used": "###invalid###, 12345-not-a-sku", + "notes": "Neither string matches any AWS usage-type pattern, so service can't be inferred; live-verified against 127.0.0.1:8123 today that both land in the top-level errors map with 'service is required — could not infer AWS servicecode' rather than a no_mapping result." + }, + { + "id": "RSKU_AWS_BATCH9", + "prompt": "Three rows in our cost export look totally malformed to me — \"just some random billing text\", \"###invalid###\", and \"12345-not-a-sku\" — none of them look like real AWS usage-type codes. Can you try pricing them and tell me what's going on?", + "category": "batch-invalid", + "cloud": "aws", + "expected_outcome": "invalid_error", + "sku_used": "just some random billing text, ###invalid###, 12345-not-a-sku", + "notes": "None of the three strings match any AWS usage-type pattern, so service can't be inferred; live-verified against 127.0.0.1:8123 today that all three land in the top-level errors map with 'service is required — could not infer AWS servicecode' rather than a no_mapping result." + }, + { + "id": "RSKU_AWS_BATCH10", + "prompt": "I've got a batch of five weird line items from this month's Cost and Usage Report and I want to reconcile all of them at once: BoxUsage:c8g.xlarge, BoxUsage:zz99.999xlarge, BoxUsage:m6a.8xlarge, \"just some random billing text\", and CAN1-BoxUsage:totallyfake.4xlarge, all us-east-1. Can you price out whichever of these are real and flag anything that isn't?", + "category": "batch-mixed", + "cloud": "aws", + "expected_outcome": "batch_mixed", + "sku_used": "BoxUsage:c8g.xlarge, BoxUsage:zz99.999xlarge, BoxUsage:m6a.8xlarge, just some random billing text, CAN1-BoxUsage:totallyfake.4xlarge", + "notes": "Mix live-verified against 127.0.0.1:8123 today in a single batch call: BoxUsage:c8g.xlarge and BoxUsage:m6a.8xlarge resolve as matched prices in results[]; BoxUsage:zz99.999xlarge and CAN1-BoxUsage:totallyfake.4xlarge come back as no_prices_found/no_mapping_in within results[]; 'just some random billing text' lands in the top-level errors map since service can't be inferred. Expect all three buckets (resolved results, no-mapping results, and errors) exercised in one response." + }, + { + "id": "RSKU_GCP_BATCH1", + "prompt": "My GCP Cloud Billing export has three SKU IDs I don't recognize, all billed against us-central1: 77F8-D8AF-3CCE, 88D6-F2EE-C781, and C054-7F72-A02E. Can you tell me what each one costs?", + "category": "batch-match", + "cloud": "gcp", + "expected_outcome": "matched", + "sku_used": "77F8-D8AF-3CCE, 88D6-F2EE-C781, C054-7F72-A02E", + "notes": "GCP SKU sourced from code comments (internal/providers/gcp/...), not independently live-verified — OCC_GCP_API_KEY unavailable" + }, + { + "id": "RSKU_GCP_BATCH2", + "prompt": "I'm reconciling a GCP invoice and see SKU IDs 77F8-D8AF-3CCE and C054-7F72-A02E on the europe-west1 line items. What's the rate for each of these?", + "category": "batch-match", + "cloud": "gcp", + "expected_outcome": "matched", + "sku_used": "77F8-D8AF-3CCE, C054-7F72-A02E", + "notes": "GCP SKU sourced from code comments (internal/providers/gcp/...), not independently live-verified — OCC_GCP_API_KEY unavailable" + }, + { + "id": "RSKU_GCP_BATCH3", + "prompt": "Two SKU IDs on my GCP bill for us-east1 that I can't match to anything internally: 88D6-F2EE-C781 and C054-7F72-A02E. What am I being charged for these, and what's the per-unit rate?", + "category": "batch-match", + "cloud": "gcp", + "expected_outcome": "matched", + "sku_used": "88D6-F2EE-C781, C054-7F72-A02E", + "notes": "GCP SKU sourced from code comments (internal/providers/gcp/...), not independently live-verified — OCC_GCP_API_KEY unavailable" + }, + { + "id": "RSKU_GCP_BATCH4", + "prompt": "My GCP billing export shows SKU IDs 0000-0000-0000 and FFFF-FFFF-FFFF for us-central1, and I can't find pricing for either one anywhere. Are these even real SKUs?", + "category": "batch-not-found", + "cloud": "gcp", + "expected_outcome": "no_mapping", + "sku_used": "0000-0000-0000, FFFF-FFFF-FFFF", + "notes": "GCP SKU sourced from code comments (internal/providers/gcp/...), not independently live-verified — OCC_GCP_API_KEY unavailable" + }, + { + "id": "RSKU_GCP_BATCH5", + "prompt": "I've got two mystery GCP SKU IDs off a europe-west1 line item: FFFF-FFFF-FFFF and 1234-5678-9ABC. Can you price these out for me?", + "category": "batch-not-found", + "cloud": "gcp", + "expected_outcome": "no_mapping", + "sku_used": "FFFF-FFFF-FFFF, 1234-5678-9ABC", + "notes": "GCP SKU sourced from code comments (internal/providers/gcp/...), not independently live-verified — OCC_GCP_API_KEY unavailable" + }, + { + "id": "RSKU_GCP_BATCH6", + "prompt": "Three SKU IDs showed up on our GCP Cloud Billing export for us-central1 that don't match anything in our records: 0000-0000-0000, 1234-5678-9ABC, and FFFF-FFFF-FFFF. What do they cost?", + "category": "batch-not-found", + "cloud": "gcp", + "expected_outcome": "no_mapping", + "sku_used": "0000-0000-0000, 1234-5678-9ABC, FFFF-FFFF-FFFF", + "notes": "GCP SKU sourced from code comments (internal/providers/gcp/...), not independently live-verified — OCC_GCP_API_KEY unavailable" + }, + { + "id": "RSKU_GCP_BATCH7", + "prompt": "Our finance team pasted these into the GCP cost spreadsheet as SKU references but they don't look like real SKU IDs to me: not-a-real-sku and gcp-fake-id. Can you check what they cost?", + "category": "batch-invalid", + "cloud": "gcp", + "expected_outcome": "invalid_error", + "sku_used": "not-a-real-sku, gcp-fake-id", + "notes": "Garbage strings that don't match GCP's skuId shape; should surface in the batch tool's top-level errors map rather than resolving. Not independently live-verified — OCC_GCP_API_KEY unavailable" + }, + { + "id": "RSKU_GCP_BATCH8", + "prompt": "Someone hand-typed these SKU references into our GCP billing tracker: gcp-fake-id and ???. Can you tell me what those bill at?", + "category": "batch-invalid", + "cloud": "gcp", + "expected_outcome": "invalid_error", + "sku_used": "gcp-fake-id, ???", + "notes": "Garbage strings that don't match GCP's skuId shape; should surface in the batch tool's top-level errors map rather than resolving. Not independently live-verified — OCC_GCP_API_KEY unavailable" + }, + { + "id": "RSKU_GCP_BATCH9", + "prompt": "I've got three garbled entries in a GCP billing export column that's supposed to hold SKU IDs: not-a-real-sku, ???, and gcp-fake-id. What are their prices?", + "category": "batch-invalid", + "cloud": "gcp", + "expected_outcome": "invalid_error", + "sku_used": "not-a-real-sku, ???, gcp-fake-id", + "notes": "Garbage strings that don't match GCP's skuId shape; should surface in the batch tool's top-level errors map rather than resolving. Not independently live-verified — OCC_GCP_API_KEY unavailable" + }, + { + "id": "RSKU_GCP_BATCH10", + "prompt": "My GCP Cloud Billing export for us-central1 has three SKU IDs I need priced all at once: 77F8-D8AF-3CCE, 0000-0000-0000, and gcp-fake-id. Can you look up all three and tell me which ones actually resolve?", + "category": "batch-mixed", + "cloud": "gcp", + "expected_outcome": "batch_mixed", + "sku_used": "77F8-D8AF-3CCE (match), 0000-0000-0000 (well-formed, not found), gcp-fake-id (invalid shape)", + "notes": "Exercises results/no_mapping/errors buckets together in one get_prices_by_sku call. The match SKU is sourced from code comments (internal/providers/gcp/...), not independently live-verified — OCC_GCP_API_KEY unavailable" + }, + { + "id": "RSKU_AZURE_BATCH1", + "prompt": "My Azure cost export for eastus this month has two meter IDs I need priced out: 3da19ca3-6007-4a29-89ea-cab10c2010ed and cf64c470-a287-5429-8dd7-756a877824a0. Can you look both up and tell me the hourly rate for each?", + "category": "batch-match", + "cloud": "azure", + "expected_outcome": "matched", + "sku_used": "3da19ca3-6007-4a29-89ea-cab10c2010ed, cf64c470-a287-5429-8dd7-756a877824a0", + "notes": "Both meterIds live-verified against 127.0.0.1:8123 today: D4s v3 on-demand ($0.192/hr) and D4s v3 Spot ($0.037632/hr) in eastus. Both should land in the batch response's results array." + }, + { + "id": "RSKU_AZURE_BATCH2", + "prompt": "I'm reconciling an Azure invoice for eastus and don't recognize two of the meter IDs on it: cf64c470-a287-5429-8dd7-756a877824a0 and 93a6a529-4f49-47cb-9b1e-db9e5f23263f. Can you pull the current rate for each?", + "category": "batch-match", + "cloud": "azure", + "expected_outcome": "matched", + "sku_used": "cf64c470-a287-5429-8dd7-756a877824a0, 93a6a529-4f49-47cb-9b1e-db9e5f23263f", + "notes": "Both meterIds live-verified against 127.0.0.1:8123 today: D4s v3 Spot ($0.037632/hr) and P10 LRS managed disk ($19.71/mo) in eastus. Both should land in results." + }, + { + "id": "RSKU_AZURE_BATCH3", + "prompt": "Our Azure cost management export for eastus lists three distinct meterId values this cycle: 3da19ca3-6007-4a29-89ea-cab10c2010ed, cf64c470-a287-5429-8dd7-756a877824a0, and 93a6a529-4f49-47cb-9b1e-db9e5f23263f. Can you get me the current price for each one so I can match them to the right line items?", + "category": "batch-match", + "cloud": "azure", + "expected_outcome": "matched", + "sku_used": "3da19ca3-6007-4a29-89ea-cab10c2010ed, cf64c470-a287-5429-8dd7-756a877824a0, 93a6a529-4f49-47cb-9b1e-db9e5f23263f", + "notes": "All three meterIds live-verified against 127.0.0.1:8123 today (D4s v3 on-demand, D4s v3 Spot, P10 LRS disk). All three should land in results." + }, + { + "id": "RSKU_AZURE_BATCH4", + "prompt": "There are two Azure meter IDs on my eastus billing export I can't find any pricing documentation for: 00000000-0000-0000-0000-000000000000 and 11111111-1111-1111-1111-111111111111. Can you check whether either one actually maps to a priced meter?", + "category": "batch-not-found", + "cloud": "azure", + "expected_outcome": "no_mapping", + "sku_used": "00000000-0000-0000-0000-000000000000, 11111111-1111-1111-1111-111111111111", + "notes": "Both are well-formed GUIDs, live-verified against 127.0.0.1:8123 today to be absent from the eastus catalog. Each result entry should carry result:'no_prices_found' with no_mapping_in populated and an empty all_regions_sorted; no top-level errors entry for either." + }, + { + "id": "RSKU_AZURE_BATCH5", + "prompt": "Two meter IDs on our Azure eastus export don't match anything I can find: 11111111-1111-1111-1111-111111111111 and 99999999-9999-9999-9999-999999999999. Can you confirm whether these are real billable meters or not?", + "category": "batch-not-found", + "cloud": "azure", + "expected_outcome": "no_mapping", + "sku_used": "11111111-1111-1111-1111-111111111111, 99999999-9999-9999-9999-999999999999", + "notes": "Both are well-formed GUIDs, live-verified against 127.0.0.1:8123 today to be absent from the eastus catalog. Each should return result:'no_prices_found' with no_mapping_in populated." + }, + { + "id": "RSKU_AZURE_BATCH6", + "prompt": "My Azure billing export for eastus has three meter IDs that look suspicious to me: 00000000-0000-0000-0000-000000000000, 11111111-1111-1111-1111-111111111111, and 99999999-9999-9999-9999-999999999999. Can you check all three against current pricing and tell me if any of them are legitimate?", + "category": "batch-not-found", + "cloud": "azure", + "expected_outcome": "no_mapping", + "sku_used": "00000000-0000-0000-0000-000000000000, 11111111-1111-1111-1111-111111111111, 99999999-9999-9999-9999-999999999999", + "notes": "All three are well-formed GUIDs, live-verified against 127.0.0.1:8123 today to be absent from the eastus catalog. All three should return result:'no_prices_found' with no_mapping_in populated, none in a top-level errors map." + }, + { + "id": "RSKU_AZURE_BATCH7", + "prompt": "Our billing export tool spat out a couple of garbled meter ID values for the eastus region: 'not-a-guid' and 'azure-meter-xyz'. Can you check if either of those actually resolves to anything priced?", + "category": "batch-invalid", + "cloud": "azure", + "expected_outcome": "no_mapping", + "sku_used": "not-a-guid, azure-meter-xyz", + "notes": "DEVIATION FROM SPEC: task instructions expected 'invalid_error' (top-level errors map) for malformed strings, modeled on AWS behavior. Live-verified against 127.0.0.1:8123 today that Azure has no reachable errors-map path for shape-invalid SKUs: per get_prices_by_sku's own docstring (internal/server/server.go ~line 3066), the top-level 'errors' map is populated only when 'a usage-type pattern no service could be inferred for' -- an AWS-only parse/inference stage. Azure's meterId lookup is direct (no parse stage), so malformed strings collapse into the same no_mapping_in/'no_prices_found' bucket as well-formed-but-absent GUIDs. Confirmed empirically: both garbage strings returned result:'no_prices_found' with no_mapping_in populated and no top-level errors key in the response at all." + }, + { + "id": "RSKU_AZURE_BATCH8", + "prompt": "Two of the meter ID fields in our eastus Azure export got mangled somehow: 'azure-meter-xyz' and '!!!bad!!!'. Can you look these up and tell me what's going on?", + "category": "batch-invalid", + "cloud": "azure", + "expected_outcome": "no_mapping", + "sku_used": "azure-meter-xyz, !!!bad!!!", + "notes": "DEVIATION FROM SPEC: same as RSKU_AZURE_BATCH7 -- live-verified against 127.0.0.1:8123 today that Azure has no reachable top-level errors-map path for shape-invalid strings (that path is AWS's usage-type-parse-failure only, per get_prices_by_sku docstring in internal/server/server.go ~line 3066). Both strings returned result:'no_prices_found' with no_mapping_in populated, no errors entry." + }, + { + "id": "RSKU_AZURE_BATCH9", + "prompt": "I've got three meter ID values from an Azure eastus export that all look wrong to me: 'not-a-guid', 'azure-meter-xyz', and '!!!bad!!!'. Can you check whether any of these actually price out to something real?", + "category": "batch-invalid", + "cloud": "azure", + "expected_outcome": "no_mapping", + "sku_used": "not-a-guid, azure-meter-xyz, !!!bad!!!", + "notes": "DEVIATION FROM SPEC: same as RSKU_AZURE_BATCH7/8 -- live-verified against 127.0.0.1:8123 today. Azure's meterId lookup has no parse/inference stage that could trigger the top-level errors map (that mechanism is AWS-specific per get_prices_by_sku docstring in internal/server/server.go ~line 3066: errors fires only for 'a usage-type pattern no service could be inferred for'). All three malformed strings returned result:'no_prices_found' with no_mapping_in populated and no top-level errors key." + }, + { + "id": "RSKU_AZURE_BATCH10", + "prompt": "I need to reconcile three meter IDs from our Azure eastus export in one go: 3da19ca3-6007-4a29-89ea-cab10c2010ed, 00000000-0000-0000-0000-000000000000, and '!!!bad!!!'. Can you check all three and tell me which ones are actually billable and at what rate?", + "category": "batch-mixed", + "cloud": "azure", + "expected_outcome": "batch_mixed", + "sku_used": "3da19ca3-6007-4a29-89ea-cab10c2010ed, 00000000-0000-0000-0000-000000000000, !!!bad!!!", + "notes": "DEVIATION FROM SPEC: task assumed a 3-way split across results/no_mapping/top-level errors (AWS-style). Live-verified against 127.0.0.1:8123 today with this exact mixed batch: Azure produces only a 2-way split -- all three SKUs appear in the 'results' array (in input order), with the real meterId (D4s v3, $0.192/hr) carrying populated all_regions_sorted/cheapest_price, and BOTH the well-formed-but-absent GUID and the garbage string carrying result:'no_prices_found' with no_mapping_in populated. There is NO top-level 'errors' key in the response at all -- Azure has no reachable errors-map path for malformed meterId strings (only AWS's usage-type-inference failure reaches errors, per get_prices_by_sku docstring, internal/server/server.go ~line 3066). The automated check should expect: 1 matched result + 2 no_mapping results, 0 errors entries." + }, + { + "id": "RSKU_AWS_BOM1", + "prompt": "My AWS Cost and Usage Report shows a cluster made up entirely of BoxUsage:c8g.xlarge line items across three different node groups — 4 of them, 2 of them, and 6 of them, all in us-east-1. Can you total up what this whole fleet costs me per month?", + "category": "raw-sku-bom", + "cloud": "aws", + "expected_outcome": "bom_estimate", + "sku_used": "BoxUsage:c8g.xlarge", + "notes": "Same raw-SKU usage-type at three different quantities (4/2/6) in one BoM — should produce 3 line_items (or one item with combined quantity, depending on how the LLM structures the call) all landing in line_items with errors:null. Live-verified: $0.15952/hr in us-east-1." + }, + { + "id": "RSKU_AWS_BOM2", + "prompt": "I'm reconciling my AWS bill for a small web tier: the CUR shows 3x BoxUsage:c8g.xlarge for the app servers, plus we're also provisioning 500GB of gp3 EBS storage for the shared volume, all in us-east-1. What's the total monthly cost for this stack?", + "category": "raw-sku-bom", + "cloud": "aws", + "expected_outcome": "bom_estimate", + "sku_used": "BoxUsage:c8g.xlarge", + "notes": "Mixes a raw-SKU compute item with a normal named-resource gp3 storage item in the same BoM. Live-verified combined total: 3x c8g.xlarge ($349.35/mo) + 500GB gp3 ($40.00/mo) = $389.35/mo, errors:null, both items in line_items." + }, + { + "id": "RSKU_AWS_BOM3", + "prompt": "We're deciding where to place a pair of m6a.8xlarge instances — the CUR usage type is BoxUsage:m6a.8xlarge. Can you compare the monthly cost of running 2 of these in us-east-1 versus eu-west-1 and tell me how much cheaper or more expensive eu-west-1 is?", + "category": "raw-sku-bom", + "cloud": "aws", + "expected_outcome": "bom_estimate", + "sku_used": "BoxUsage:m6a.8xlarge", + "notes": "compare_bom_regions across two regions that actually differ. Live-verified: us-east-1 $1.3824/hr -> 2x = $2018.30/mo; eu-west-1 $1.5408/hr -> 2x = $2249.57/mo (us-west-2 ties us-east-1 exactly, so deliberately excluded to keep the 'cost shifts' framing checkable). expected_outcome is per-region bom_estimate; the check should see distinct totals across the two regions." + }, + { + "id": "RSKU_AWS_BOM4", + "prompt": "I've got a batch-processing job that only runs 200 hours a month (not 24/7) using 5x BoxUsage:c8g.xlarge instances in us-east-1. What would that actually cost me monthly given the reduced runtime?", + "category": "raw-sku-bom", + "cloud": "aws", + "expected_outcome": "bom_estimate", + "sku_used": "BoxUsage:c8g.xlarge", + "notes": "Raw-SKU BoM item with quantity=5 and hours_per_month=200 (well under the 730 default) instead of assuming full-month uptime. Live-verified: 5 x $0.15952/hr x 200hr = $159.52/mo, errors:null." + }, + { + "id": "RSKU_AWS_BOM5", + "prompt": "My billing export lists two compute line items I need to estimate together: 2x BoxUsage:c7i.2xlarge and 1x BoxUsage:z9.fake, both in us-east-1. Can you total up what this stack costs per month, and flag anything you can't price?", + "category": "raw-sku-bom", + "cloud": "aws", + "expected_outcome": "bom_item_not_found", + "sku_used": "BoxUsage:c7i.2xlarge, BoxUsage:z9.fake", + "notes": "Per-item error handling: the fake usage type has no catalog mapping and should land in the errors array (message: 'no pricing mapping ... tried service(s): [AmazonEC2]') while BoxUsage:c7i.2xlarge (live-verified $0.357/hr, 2x = $521.22/mo) still resolves into line_items — a partial estimate, not a total failure." + }, + { + "id": "RSKU_AWS_BOM6", + "prompt": "I'm sizing out a small stack: 2x BoxUsage:c8g.xlarge for the app tier, plus 1x InstanceUsage:db.r6g.large for the database, both from the us-east-1 CUR. Does this whole thing estimate cleanly, or is one of these line items going to need more info from me?", + "category": "raw-sku-bom", + "cloud": "aws", + "expected_outcome": "bom_item_ambiguous", + "sku_used": "BoxUsage:c8g.xlarge, InstanceUsage:db.r6g.large", + "notes": "Live-verified: InstanceUsage:db.r6g.large is ambiguous in a BoM context too (5 matching product rows — MySQL/PostgreSQL/Aurora variants at different rates) and lands in errors ('is ambiguous in region ... 5 matching rows ... supply operation/product_family'), NOT line_items — this is NOT a clean bom_estimate despite the prompt inviting that reading. Paired with the resolvable c8g.xlarge item ($232.90/mo for 2x) so the trace shows partial success (1 line_item + 1 error), not a fully-failed BoM." + }, + { + "id": "RSKU_AWS_BOM7", + "prompt": "For a burst-capacity plan I want to compare 3x BoxUsage:m6a.8xlarge across us-east-1, us-west-2, and eu-west-1 — which region comes out cheapest for this instance type and by how much per month?", + "category": "raw-sku-bom", + "cloud": "aws", + "expected_outcome": "bom_estimate", + "sku_used": "BoxUsage:m6a.8xlarge", + "notes": "compare_bom_regions across three regions. Live-verified per-region totals for 3x m6a.8xlarge: us-east-1 = $1.3824/hr x 3 x 730 = $3027.456/mo, us-west-2 identical to us-east-1 (same $1.3824/hr rate) = $3027.456/mo, eu-west-1 = $1.5408/hr x 3 x 730 = $3374.352/mo — us-east-1/us-west-2 tie for cheapest, eu-west-1 is priciest; each region's bom_estimate should show line_items with errors:null." + }, + { + "id": "RSKU_GCP_BOM1", + "prompt": "My GCP Cloud Billing export has two Cloud KMS line items I want folded into a monthly estimate, both in us-central1: skuId 77F8-D8AF-3CCE (active HSM symmetric key versions for our Autokey setup) and skuId 88D6-F2EE-C781 (HSM symmetric crypto operations for Autokey). Staging runs about 60 key versions and 8,000 crypto operations a month; production runs about 400 key versions and 60,000 crypto operations a month. Can you build a combined monthly cost estimate across both environments?", + "category": "raw-sku-bom", + "cloud": "gcp", + "expected_outcome": "bom_estimate", + "sku_used": "77F8-D8AF-3CCE, 88D6-F2EE-C781", + "notes": "Entirely-raw-SKU BoM (variation a): 4 line items, all raw-SKU, same KMS Autokey domain, quantities split across two environments. Both SKUs are tiered (free-then-paid): 77F8-D8AF-3CCE gives 100 free key-versions/mo then $1.00/version/mo (staging's 60 should land fully in the free tier, prod's 400 should straddle it); 88D6-F2EE-C781 gives 10,000 free ops/mo then $0.000003/op (staging's 8,000 free, prod's 60,000 straddles it). Each item should resolve as a single matched (non-ambiguous) tiered line item via gcpGraduatedTieredCost, not as ambiguous. GCP SKU sourced from code comments (internal/providers/gcp/gcp_kms.go, gcp_kms_test.go), not independently live-verified — OCC_GCP_API_KEY unavailable in the verification environment." + }, + { + "id": "RSKU_GCP_BOM2", + "prompt": "I'm putting together a monthly estimate for a small stack in us-central1: 3 n2-standard-4 Compute Engine instances running 24/7, plus the external IP charge for those same 3 VMs which shows up in my billing export as skuId C054-7F72-A02E, plus 200GB of pd-ssd persistent disk. What's the total monthly cost?", + "category": "raw-sku-bom", + "cloud": "gcp", + "expected_outcome": "bom_estimate", + "sku_used": "C054-7F72-A02E", + "notes": "Mixed BoM (variation b): a raw-SKU item (external IP charge, quantity=3, default hours_per_month=730) alongside two normal named-resource items (n2-standard-4 compute, pd-ssd storage) resolved through the ordinary domain/resource_type spec path. C054-7F72-A02E is a two-tier SKU ($0.005/hr paid tier plus a free quota tier) that should resolve as a single matched tiered line item, not ambiguous. GCP SKU sourced from code comments (internal/providers/gcp/gcp_networking.go, gcp_networking_test.go), not independently live-verified — OCC_GCP_API_KEY unavailable in the verification environment." + }, + { + "id": "RSKU_GCP_BOM3", + "prompt": "I have a Compute Engine external IP charge in my billing export — skuId C054-7F72-A02E — for 5 VMs running 24/7, and I'm deciding which region to deploy in. Can you compare the monthly cost of just that external IP charge across us-central1, europe-west4, and asia-southeast1?", + "category": "raw-sku-bom", + "cloud": "gcp", + "expected_outcome": "bom_region_comparison", + "sku_used": "C054-7F72-A02E", + "notes": "Single raw-SKU item run through compare_bom_regions across 3 regions (variation c). C054-7F72-A02E has literal serviceRegions=[\"global\"] in the Billing Catalog, and gcp.go's skuMatchesRegion treats the \"global\" sentinel as matching any requested region — so per gcpSKUMatchesRequestedRegion (internal/providers/gcp/gcp_sku_lookup.go) this item should resolve to the SAME monthly cost in all three regions, not a shifting one; expected_outcome intentionally is NOT a cost delta across regions. Uses compare_bom_regions' per-region comparison result shape, not estimate_bom's line-item shape, hence the distinct label. GCP SKU sourced from code comments (internal/providers/gcp/gcp_networking.go, gcp_networking_test.go, gcp_sku_lookup.go), not independently live-verified — OCC_GCP_API_KEY unavailable in the verification environment." + }, + { + "id": "RSKU_GCP_BOM4", + "prompt": "My dev team spins up 8 Compute Engine VMs with external IPs, but only during work hours — about 300 hours a month per VM, not 24/7. The billing export tags this as skuId C054-7F72-A02E in us-east1. What would the external IP portion of my monthly bill look like for those 8 dev VMs at 300 hours/month each?", + "category": "raw-sku-bom", + "cloud": "gcp", + "expected_outcome": "bom_estimate", + "sku_used": "C054-7F72-A02E", + "notes": "Raw-SKU BoM item with explicit non-default quantity (8, default is 1) and hours_per_month (300, default is 730) to model non-24/7 usage (variation d). C054-7F72-A02E is PER_HOUR-unit and tiered; bomMonthlyCost/gcpGraduatedTieredCost scale by hours_per_month*quantity (2,400 VM-hours), not raw quantity, so the tier-threshold check and the displayed monthly cost should both reflect the reduced usage window. GCP SKU sourced from code comments (internal/providers/gcp/gcp_networking.go, gcp_networking_test.go), not independently live-verified — OCC_GCP_API_KEY unavailable in the verification environment." + }, + { + "id": "RSKU_GCP_BOM5", + "prompt": "I'm building a monthly estimate for our GCP footprint in us-central1: 200 Cloud KMS Autokey HSM key versions under skuId 77F8-D8AF-3CCE, plus another line item from the billing export tagged skuId 1234-5678-90AB that I can't find documented anywhere. Can you estimate the whole bundle and flag anything that doesn't resolve?", + "category": "raw-sku-bom", + "cloud": "gcp", + "expected_outcome": "bom_item_not_found", + "sku_used": "77F8-D8AF-3CCE, 1234-5678-90AB", + "notes": "Per-item error handling (variation e): a real, resolvable raw-SKU item (77F8-D8AF-3CCE, 200 versions — straddles the 100-free-version tier boundary) mixed with a deliberately-fake skuId-shaped string (1234-5678-90AB) that should not resolve in any GCP service catalog. Expect the BoM response to carry a per-item error/no-mapping entry for the fake SKU while still costing the real item, rather than aborting the whole call. IMPORTANT CAVEAT: this test's discriminator (fake SKU -> no_mapping/could-not-resolve vs. real SKU -> resolved) only holds when GCP credentials are actually configured; without OCC_GCP_API_KEY (as in the verification environment used to write this test) BOTH the real and fake SKU return the same catalog-fetch error, making them indistinguishable — this label describes the GCP-configured/intended behavior the suite is meant to exercise, not what was observed live. GCP SKU sourced from code comments (internal/providers/gcp/gcp_kms.go, gcp_kms_test.go), not independently live-verified." + }, + { + "id": "RSKU_GCP_BOM6", + "prompt": "Our Cloud KMS Autokey usage varies a lot by environment. The billing export shows skuId 88D6-F2EE-C781 (HSM symmetric crypto operations for Autokey), all in us-central1, at roughly 2,000 operations/month in dev, 9,500 in staging, and 45,000 in production. Can you build a combined monthly cost estimate across those three environments?", + "category": "raw-sku-bom", + "cloud": "gcp", + "expected_outcome": "bom_estimate", + "sku_used": "88D6-F2EE-C781", + "notes": "Entirely-raw-SKU BoM (variation a, single-SKU strict form): 3 line items, all the same raw SKU (88D6-F2EE-C781) at different quantities, deliberately straddling the 10,000-free-op/mo tier boundary (dev and staging both land inside the free tier at 2,000 and 9,500; production at 45,000 crosses it, so 10,000 free + 35,000 paid at $0.000003/op = $0.105 for that line). Exercises gcpGraduatedTieredCost per line item with distinct quantities against the same tier schedule. GCP SKU sourced from code comments (internal/providers/gcp/gcp_kms.go, gcp_kms_test.go), not independently live-verified — OCC_GCP_API_KEY unavailable in the verification environment." + }, + { + "id": "RSKU_GCP_BOM7", + "prompt": "We're deciding between us-central1 and europe-west4 for a new deployment: 2 n2-standard-4 Compute Engine instances, 100GB of pd-balanced storage, and a Cloud KMS Autokey line item from our billing export — skuId 77F8-D8AF-3CCE, 150 active HSM key versions/month. Can you compare the total monthly cost of this whole stack across both regions?", + "category": "raw-sku-bom", + "cloud": "gcp", + "expected_outcome": "bom_region_comparison", + "sku_used": "77F8-D8AF-3CCE", + "notes": "Mixed BoM (raw-SKU KMS item + normal named compute/storage items, variation b) run through compare_bom_regions across 2 regions (variation c combined). 77F8-D8AF-3CCE is a KMS Autokey SKU which per gcp_kms.go's kmsServiceID handling is treated as region-invariant (Region reported as \"global\" regardless of requested region, per this file's own geoTaxonomy-first precedent) — so its cost contribution (100 free + 50 paid at $1.00/version = $50/mo) should be identical in both regions; only the compute/storage portions can vary by region. Uses compare_bom_regions' per-region comparison shape, hence the distinct label from plain bom_estimate. GCP SKU sourced from code comments (internal/providers/gcp/gcp_kms.go, gcp_kms_test.go), not independently live-verified — OCC_GCP_API_KEY unavailable in the verification environment." + }, + { + "id": "RSKU_AZURE_BOM1", + "prompt": "My Azure cost export lists two Virtual Machines line items under the same Dv3 family: meterId 3da19ca3-6007-4a29-89ea-cab10c2010ed for 3 on-demand instances, and meterId cf64c470-a287-5429-8dd7-756a877824a0 for 2 Spot instances, both in eastus running 24/7. Can you estimate my total monthly bill for this VM fleet?", + "category": "raw-sku-bom", + "cloud": "azure", + "expected_outcome": "bom_estimate", + "sku_used": "3da19ca3-6007-4a29-89ea-cab10c2010ed (x3), cf64c470-a287-5429-8dd7-756a877824a0 (x2)", + "notes": "Entirely raw-SKU items of the same type (VM) at different quantities, per variation (a). Live-verified via estimate_bom: item1 3x$0.192/hr*730=$420.48/mo, item2 2x$0.037632/hr*730=$54.94/mo, totals.monthly=$475.42/mo, no errors." + }, + { + "id": "RSKU_AZURE_BOM2", + "prompt": "I've got two rows in my Azure billing export for the same managed-disk meter, meterId 93a6a529-4f49-47cb-9b1e-db9e5f23263f, in eastus — one for 12 disks attached to production and one for 2 disks in a dev environment. What's my total monthly disk spend across both?", + "category": "raw-sku-bom", + "cloud": "azure", + "expected_outcome": "bom_estimate", + "sku_used": "93a6a529-4f49-47cb-9b1e-db9e5f23263f (x12 and x2)", + "notes": "Entirely raw-SKU items of the same type (storage) at different quantities, per variation (a) with a second same-type example beyond BOM1's compute one. Live-verified: 12x$19.71=$236.52/mo, 2x$19.71=$39.42/mo, totals.monthly=$275.94/mo, no errors." + }, + { + "id": "RSKU_AZURE_BOM3", + "prompt": "I'm reconciling a mixed-cloud bill. On the Azure side there are 2 D4s v3 VMs billed under meterId 3da19ca3-6007-4a29-89ea-cab10c2010ed in eastus for our app servers, and on the AWS side we're storing 500GB of gp3 log storage in us-east-1. What's the combined monthly cost of that stack?", + "category": "raw-sku-bom", + "cloud": "azure", + "expected_outcome": "bom_estimate", + "sku_used": "3da19ca3-6007-4a29-89ea-cab10c2010ed (azure raw-SKU) + aws gp3 named-resource item", + "notes": "Variation (b): raw-SKU item mixed with a normal named-resource item, cross-provider in the same BoM (task's own (b) example is cross-provider-flavored). Live-verified: azure line $280.32/mo + aws gp3 500GB line $40.00/mo = totals.monthly=$320.32/mo, errors:null. Note the AWS gp3 leg also populates a not_included advisory block (CloudWatch, EBS snapshots) — don't key expected_outcome on not_included being empty, just on both line items resolving and totals being present." + }, + { + "id": "RSKU_AZURE_BOM4", + "prompt": "For our eastus environment I have 4 web-tier VMs I'd size as Standard_D4s_v3, plus 4 attached P10 managed data disks billed under meterId 93a6a529-4f49-47cb-9b1e-db9e5f23263f — one disk per VM. Can you estimate the combined monthly cost of the VMs and their attached disks?", + "category": "raw-sku-bom", + "cloud": "azure", + "expected_outcome": "bom_estimate", + "sku_used": "93a6a529-4f49-47cb-9b1e-db9e5f23263f (raw-SKU) + azure named-resource Standard_D4s_v3 VM item", + "notes": "Variation (b) same-provider mix: raw-SKU disk item plus a normal named-resource (domain/resource_type) Azure VM item in one BoM. Live-verified: VM line $560.64/mo (4x$0.192/hr*730) + disk line $78.84/mo (4x$19.71) = totals.monthly=$639.48/mo, errors:null." + }, + { + "id": "RSKU_AZURE_BOM5", + "prompt": "We're deciding where to run 2 D4s v3 VMs — the CUR/billing meter we're currently paying under is 3da19ca3-6007-4a29-89ea-cab10c2010ed, and today they run in eastus. Can you compare our current eastus rate for those 2 VMs against running the same 2 VMs in westus2 or centralus instead?", + "category": "raw-sku-bom", + "cloud": "azure", + "expected_outcome": "bom_region_partial", + "sku_used": "3da19ca3-6007-4a29-89ea-cab10c2010ed", + "notes": "Variation (c): raw-SKU BoM item run through compare_bom_regions across 3 regions (eastus explicitly pinned as the current/baseline region alongside the two alternatives, so it's guaranteed to be in the regions list). Live-verified with regions=[eastus,westus2,centralus], baseline_region=eastus: eastus resolves ok at total_monthly=$280.32/mo (2x$0.192/hr*730), delta_monthly=+$0.00 vs itself; westus2 and centralus both come back status=no_data with errors=[\"Item 1: sku ... has no pricing mapping in region 'westus2'/'centralus'...\"] and total_monthly=$0.00 — this specific Azure meterId is region-locked (Azure Retail Prices meterIds are per-region), so only the baseline region resolves and the other two are explicitly no_data, not silently dropped or estimated." + }, + { + "id": "RSKU_AZURE_BOM6", + "prompt": "We run 3 D4s v3 Spot instances (meterId cf64c470-a287-5429-8dd7-756a877824a0) plus 3 attached P10 managed data disks (meterId 93a6a529-4f49-47cb-9b1e-db9e5f23263f) in eastus for a batch job — the VMs only run about 300 hours a month since the job shuts down nights and weekends, but the disks stay attached and billed 24/7. What would that combination cost us monthly?", + "category": "raw-sku-bom", + "cloud": "azure", + "expected_outcome": "bom_estimate", + "sku_used": "cf64c470-a287-5429-8dd7-756a877824a0 (hours_per_month=300, qty 3), 93a6a529-4f49-47cb-9b1e-db9e5f23263f (qty 3, default monthly disk rate)", + "notes": "Variation (d): explicit quantity>1 and hours_per_month<730 (non-24/7 usage) on a raw-SKU compute item. A second raw-SKU line item (the disks) is included specifically so the request can only be answered by resolving a multi-item BoM (estimate_bom), not by a single get_price_by_sku call the model multiplies by hand. Live-verified: Spot VM line $33.87/mo (3x$0.037632/hr*300hr, NOT the 730hr default), disk line $59.13/mo (3x$19.71), totals.monthly=$93.00/mo, errors:null." + }, + { + "id": "RSKU_AZURE_BOM7", + "prompt": "My Azure export has 6 P10 managed disks in eastus under meterId 93a6a529-4f49-47cb-9b1e-db9e5f23263f, plus one more row with meterId 00000000-0000-0000-0000-badf00d00000 that I can't identify. Can you estimate my monthly disk spend and flag anything you can't price?", + "category": "raw-sku-bom", + "cloud": "azure", + "expected_outcome": "bom_item_not_found", + "sku_used": "93a6a529-4f49-47cb-9b1e-db9e5f23263f (resolves, x6), 00000000-0000-0000-0000-badf00d00000 (deliberately fake, does not resolve)", + "notes": "Variation (e): one BoM entry with a SKU that will not resolve, alongside otherwise-valid items, to exercise per-item error handling. Live-verified via estimate_bom: the fake-GUID item produces errors=[\"Item 2: sku \\\"00000000-0000-0000-0000-badf00d00000\\\" has no pricing mapping in region 'eastus' (tried service(s): [])\"] while the 6x P10 disk item still resolves cleanly at line monthly_cost=$118.26/mo and totals.monthly=$118.26/mo — i.e. a populated errors array coexisting with a non-empty line_items/totals, not a hard failure of the whole call." + }, + { + "id": "RSKU_ERR1", + "prompt": "My AWS Cost and Usage Report has a line item for BoxUsage:c8g.xlarge but I forgot to note which region it's billed in — can you tell me what that usage type costs?", + "category": "protocol-edge-case", + "cloud": "aws", + "expected_outcome": "regions_required_or_clarify", + "sku_used": "BoxUsage:c8g.xlarge", + "notes": "Probes get_price_by_sku's regions_required path (confirmed live: sku='BoxUsage:c8g.xlarge', regions=[] returns {\"error\":\"regions_required\",\"message\":\"regions must contain at least one AWS region code\"}). Elicitation caveat: the LLM may instead silently default to a region like us-east-1 and return a clean 'matched' result rather than omitting regions or asking for clarification — that branch is not covered by this label, so a 'matched' trace with an assumed region should be treated as the LLM sidestepping the edge case, not a check failure against server validation." + }, + { + "id": "RSKU_ERR2", + "prompt": "I'm about to reconcile a stack of AWS usage-type codes from this month's billing export, but I haven't pulled the actual list together yet — can you get the batch SKU pricing check ready to go so I can just hand you the codes in a minute?", + "category": "protocol-edge-case", + "cloud": "aws", + "expected_outcome": "skus_required", + "sku_used": "", + "notes": "Probes get_prices_by_sku's skus_required error (confirmed live: skus=[] returns {\"error\":\"skus_required\",\"message\":\"skus must contain at least one raw SKU/usage-type string\"}). Hard to elicit purely via prompt: a competent LLM will very likely just wait/ask for the actual codes rather than invoke the batch tool with an empty skus array, since there is nothing yet to check — the label has no clarify escape hatch, so a trace with no tool call at all should be read as the LLM declining to force the edge case, not as a failed skus_required check. The server-side validation itself is what's under test here." + }, + { + "id": "RSKU_ERR3", + "prompt": "I moved one workload over to Oracle Cloud (OCI) and my OCI bill lists a line item by its raw SKU code — can you look up its current rate the same way you did for my AWS usage-type codes?", + "category": "protocol-edge-case", + "cloud": "oci", + "expected_outcome": "unsupported_provider", + "sku_used": "(unspecified OCI SKU code)", + "notes": "Probes the unsupported_provider path shared by get_price_by_sku/get_prices_by_sku (confirmed live: provider='oci' returns {\"error\":\"unsupported_provider\",\"message\":\"get_price_by_sku does not support provider \\\"oci\\\".\"}). Elicitation caveat: a well-informed LLM may recognize OCI isn't covered and say so directly without ever calling the tool, producing no trace to check — that is a plausible, acceptable outcome distinct from a failed unsupported_provider check." + }, + { + "id": "RSKU_ERR4", + "prompt": "My monthly AWS Cost and Usage Report export lists these usage types, all billed in us-east-1 — can you price all of these in one shot: BoxUsage:x1.large, BoxUsage:x2.large, BoxUsage:x3.large, BoxUsage:x4.large, BoxUsage:x5.large, BoxUsage:x6.large, BoxUsage:x7.large, BoxUsage:x8.large, BoxUsage:x9.large, BoxUsage:x10.large, BoxUsage:x11.large, BoxUsage:x12.large, BoxUsage:x13.large, BoxUsage:x14.large, BoxUsage:x15.large, BoxUsage:x16.large, BoxUsage:x17.large, BoxUsage:x18.large, BoxUsage:x19.large, BoxUsage:x20.large, BoxUsage:x21.large, BoxUsage:x22.large, BoxUsage:x23.large, BoxUsage:x24.large, BoxUsage:x25.large, BoxUsage:x26.large, BoxUsage:x27.large, BoxUsage:x28.large, BoxUsage:x29.large, BoxUsage:x30.large?", + "category": "protocol-edge-case", + "cloud": "aws", + "expected_outcome": "too_many_skus_or_chunked", + "sku_used": "BoxUsage:x1.large..BoxUsage:x30.large (30 items)", + "notes": "Probes get_prices_by_sku's maxSKUsPerBatch=25 cap (internal/tools/sku_lookup.go:530; confirmed live: 26 skus returns {\"error\":\"too_many_skus\",\"message\":\"skus must contain at most 25 entries (got 26) — call get_prices_by_sku in smaller batches, or use get_price_by_sku for one-off lookups.\"}). Prompt phrased as 'in one shot' to bias the LLM toward a single 30-item call (fires too_many_skus) rather than pre-emptively chunking into batches of <=25 (also an acceptable outcome per the _or_chunked label)." + }, + { + "id": "RSKU_ERR5", + "prompt": "I have a line item on my AWS bill but the usage type column is blank — can you still figure out what it costs?", + "category": "protocol-edge-case", + "cloud": "aws", + "expected_outcome": "sku_required_or_cannot_resolve", + "sku_used": "", + "notes": "Probes get_price_by_sku's sku_required error (confirmed live: sku='', regions=['us-east-1'] returns {\"error\":\"sku_required\",\"message\":\"sku must not be empty\"}). Hard to elicit purely via prompt: a competent LLM will most likely explain that it needs the actual SKU/usage-type string and ask the user to supply it, rather than calling the tool with an empty sku argument — a no-call trace should be read as the LLM correctly declining rather than a failed sku_required check. The server-side validation is what's actually under test here." + } +] diff --git a/local-test-harness/run_tests.py b/local-test-harness/run_tests.py index 1505156..763bd93 100644 --- a/local-test-harness/run_tests.py +++ b/local-test-harness/run_tests.py @@ -1426,6 +1426,426 @@ def _load_dotenv(env_file: Path) -> None: " Standard, and state whether any of the three test volumes crosses" " that threshold." ), + # ----------------------------------------------------------------------- + # Raw-SKU lookup — AWS raw-SKU success (RSKU) + # ----------------------------------------------------------------------- + "RSKU_AWS_OK1": ( + "Our AWS Cost and Usage Report has a line item with usage type \"BoxUsage:c8g.xlarge\" " + "billed in us-east-1. What's the on-demand hourly rate for that usage type so I can " + "check it against what we were actually charged?" + ), + "RSKU_AWS_OK2": ( + "I'm reconciling our EC2 bill and one CUR row shows usage type BoxUsage:m6a.8xlarge in " + "us-east-1. Can you pull the current public on-demand rate for that exact usage type " + "and tell me if it lines up with $1.3824/hr?" + ), + "RSKU_AWS_OK3": ( + "In our billing export there's a usage type of BoxUsage:c7i.2xlarge running in " + "us-east-1. Can you give me both the hourly rate and what that works out to per month " + "if it runs 24/7?" + ), + # ----------------------------------------------------------------------- + # Raw-SKU lookup — Azure no-mapping (RSKU) + # ----------------------------------------------------------------------- + "RSKU_AZ_NF1": ( + "Our Azure billing export has a line item with meter ID " + "00000000-0000-0000-0000-000000000000 in the eastus region, but I can't find a current " + "rate for it anywhere. What does this meter ID actually correspond to, and what's the " + "current price?" + ), + # ----------------------------------------------------------------------- + # Raw-SKU lookup — Azure raw-SKU success (RSKU) + # ----------------------------------------------------------------------- + "RSKU_AZ_OK1": ( + "I'm reconciling our Azure CUR-style usage export and one line shows meter ID " + "3da19ca3-6007-4a29-89ea-cab10c2010ed for the eastus region. What VM SKU is that, and " + "what's the current hourly rate?" + ), + # ----------------------------------------------------------------------- + # Raw-SKU lookup — ambiguous match (RSKU) + # ----------------------------------------------------------------------- + "RSKU_AWS_AMB1": ( + "My AWS Cost and Usage Report has a line item with usage type just \"LCUUsage\" in " + "us-east-1 — no BoxUsage prefix, and the export doesn't say which load balancer it's " + "billing. What's the hourly rate for that?" + ), + "RSKU_AWS_AMB2": ( + "I'm reconciling my AWS bill and see a CUR line item with usage type " + "\"InstanceUsage:db.r6g.large\" in us-east-1 — that's my MySQL RDS instance, right? " + "What's the hourly rate for it?" + ), + "RSKU_GCP_AMB1": ( + "My GCP billing export has a Cloud KMS line item with skuId \"1017-1BAF-7159\" — what's " + "the rate for those HSM asymmetric key versions?" + ), + "RSKU_GCP_AMB2": ( + "There's a GCP Cloud KMS charge on my bill for skuId \"4A51-C764-8B93\", described as " + "\"Active Single Tenant HSM key versions (above 15000)\" — what does that cost per month?" + ), + "RSKU_AZ_AMB1": ( + "My Azure invoice has a Network Watcher connection-monitor charge in West US with meter " + "ID \"ba2b4df6-e886-4cf2-9818-33f27d22b3cf\" — what's the per-unit rate for that?" + ), + "RSKU_AZ_AMB2": ( + "My Azure invoice has an Azure Database for MySQL Single Server (Gen5, General Purpose) " + "compute charge in UK South with meter ID \"ace03b73-4864-4a8c-afcb-55ddf91e010e\" — " + "what's the hourly compute rate for that vCore?" + ), + # ----------------------------------------------------------------------- + # Raw-SKU lookup — tiered rate (RSKU) + # ----------------------------------------------------------------------- + "RSKU_GCP_TIER1": ( + "Our GCP billing export has a Cloud KMS line item with SKU ID 77F8-D8AF-3CCE for " + "Autokey key-versions. Right now we're under 100 key versions a month and it's showing " + "$0. If our key-version count grows past 100 next quarter, does the per-unit rate " + "actually kick in at that point, or does this SKU stay free no matter how much we use?" + ), + "RSKU_GCP_TIER2": ( + "We're reconciling a Cloud KMS charge with SKU ID 1017-1BAF-7159 for HSM asymmetric key " + "versions. Our HSM key usage is ramping up fast — is there a volume discount that kicks " + "in once we cross 2000 key versions in a month, or does this SKU charge the same rate " + "no matter how much we use?" + ), + "RSKU_AZ_TIER1": ( + "On our Azure invoice, meter ID 6bd64e8e-5cb9-49d3-893d-800c9b28dca3 shows up for " + "standard outbound data transfer in southcentralus. Some months we push well past " + "10,000 GB of egress — does the per-GB rate step down once we hit higher volumes, or is " + "this a single flat rate no matter how much we send?" + ), + "RSKU_AZ_TIER2": ( + "We have meter ID 9995d93a-7d35-4d3f-9c69-7a7fea447ef4 on our Azure bill for data " + "transfer out of mexicocentral. Our egress there is climbing past 50,000 GB some months " + "— is there a lower per-GB rate once we cross that volume, or does this meter bill flat " + "regardless of usage?" + ), + # ----------------------------------------------------------------------- + # Raw-SKU lookup — batch match (RSKU) + # ----------------------------------------------------------------------- + "RSKU_AWS_BATCH1": ( + "I'm reconciling our AWS Cost and Usage Report for the compute team and I've got a few " + "EC2 usage-type line items I need current on-demand rates for, all in us-east-1: " + "BoxUsage:c8g.xlarge and BoxUsage:m6a.8xlarge. Can you price both out for me in one go?" + ), + "RSKU_AWS_BATCH2": ( + "Our finance team pulled these three EC2 usage-type codes off the billing export and " + "wants a per-hour rate check for us-east-1: BoxUsage:c8g.xlarge, BoxUsage:c7i.2xlarge, " + "and BoxUsage:m6a.8xlarge. Can you pull current on-demand pricing for all three at " + "once?" + ), + "RSKU_AWS_BATCH3": ( + "Quick sanity check on two line items from our AWS bill, both us-east-1: " + "BoxUsage:c7i.2xlarge and BoxUsage:c8g.xlarge. What's the hourly rate on each?" + ), + "RSKU_GCP_BATCH1": ( + "My GCP Cloud Billing export has three SKU IDs I don't recognize, all billed against " + "us-central1: 77F8-D8AF-3CCE, 88D6-F2EE-C781, and C054-7F72-A02E. Can you tell me what " + "each one costs?" + ), + "RSKU_GCP_BATCH2": ( + "I'm reconciling a GCP invoice and see SKU IDs 77F8-D8AF-3CCE and C054-7F72-A02E on the " + "europe-west1 line items. What's the rate for each of these?" + ), + "RSKU_GCP_BATCH3": ( + "Two SKU IDs on my GCP bill for us-east1 that I can't match to anything internally: " + "88D6-F2EE-C781 and C054-7F72-A02E. What am I being charged for these, and what's the " + "per-unit rate?" + ), + "RSKU_AZURE_BATCH1": ( + "My Azure cost export for eastus this month has two meter IDs I need priced out: " + "3da19ca3-6007-4a29-89ea-cab10c2010ed and cf64c470-a287-5429-8dd7-756a877824a0. Can you " + "look both up and tell me the hourly rate for each?" + ), + "RSKU_AZURE_BATCH2": ( + "I'm reconciling an Azure invoice for eastus and don't recognize two of the meter IDs " + "on it: cf64c470-a287-5429-8dd7-756a877824a0 and 93a6a529-4f49-47cb-9b1e-db9e5f23263f. " + "Can you pull the current rate for each?" + ), + "RSKU_AZURE_BATCH3": ( + "Our Azure cost management export for eastus lists three distinct meterId values this " + "cycle: 3da19ca3-6007-4a29-89ea-cab10c2010ed, cf64c470-a287-5429-8dd7-756a877824a0, and " + "93a6a529-4f49-47cb-9b1e-db9e5f23263f. Can you get me the current price for each one so " + "I can match them to the right line items?" + ), + # ----------------------------------------------------------------------- + # Raw-SKU lookup — batch not-found (RSKU) + # ----------------------------------------------------------------------- + "RSKU_AWS_BATCH4": ( + "I've got some odd EC2 usage-type strings in our Cost and Usage Report that I don't " + "recognize from any instance family we run: BoxUsage:zz99.999xlarge and " + "BoxUsage:nonexistent.type, both in us-east-1. Can you check what these actually cost, " + "or flag if they're not real instance types?" + ), + "RSKU_AWS_BATCH5": ( + "Two more mystery line items showed up on the export this month, both us-east-1: " + "BoxUsage:zz99.999xlarge and CAN1-BoxUsage:totallyfake.4xlarge. Neither matches any " + "instance type our team has ever provisioned — can you look them up and tell me what " + "they resolve to?" + ), + "RSKU_AWS_BATCH6": ( + "Trying to true up last month's compute spend and three of the usage-type codes on the " + "report don't ring a bell: BoxUsage:nonexistent.type, " + "CAN1-BoxUsage:totallyfake.4xlarge, and BoxUsage:zz99.999xlarge, all us-east-1. Can you " + "check current pricing for these and let me know if any of them just aren't real SKUs?" + ), + "RSKU_GCP_BATCH4": ( + "My GCP billing export shows SKU IDs 0000-0000-0000 and FFFF-FFFF-FFFF for us-central1, " + "and I can't find pricing for either one anywhere. Are these even real SKUs?" + ), + "RSKU_GCP_BATCH5": ( + "I've got two mystery GCP SKU IDs off a europe-west1 line item: FFFF-FFFF-FFFF and " + "1234-5678-9ABC. Can you price these out for me?" + ), + "RSKU_GCP_BATCH6": ( + "Three SKU IDs showed up on our GCP Cloud Billing export for us-central1 that don't " + "match anything in our records: 0000-0000-0000, 1234-5678-9ABC, and FFFF-FFFF-FFFF. " + "What do they cost?" + ), + "RSKU_AZURE_BATCH4": ( + "There are two Azure meter IDs on my eastus billing export I can't find any pricing " + "documentation for: 00000000-0000-0000-0000-000000000000 and " + "11111111-1111-1111-1111-111111111111. Can you check whether either one actually maps " + "to a priced meter?" + ), + "RSKU_AZURE_BATCH5": ( + "Two meter IDs on our Azure eastus export don't match anything I can find: " + "11111111-1111-1111-1111-111111111111 and 99999999-9999-9999-9999-999999999999. Can you " + "confirm whether these are real billable meters or not?" + ), + "RSKU_AZURE_BATCH6": ( + "My Azure billing export for eastus has three meter IDs that look suspicious to me: " + "00000000-0000-0000-0000-000000000000, 11111111-1111-1111-1111-111111111111, and " + "99999999-9999-9999-9999-999999999999. Can you check all three against current pricing " + "and tell me if any of them are legitimate?" + ), + # ----------------------------------------------------------------------- + # Raw-SKU lookup — batch invalid SKU (RSKU) + # ----------------------------------------------------------------------- + "RSKU_AWS_BATCH7": ( + "I copy-pasted a couple of lines from our billing export into a spreadsheet and I think " + "the columns got scrambled — these don't look like real SKU codes to me: \"just some " + "random billing text\" and \"12345-not-a-sku\". Can you check whether either of these " + "actually prices out to anything on AWS?" + ), + "RSKU_AWS_BATCH8": ( + "Our export tool spit out some garbage-looking entries this run — \"###invalid###\" and " + "\"12345-not-a-sku\" — instead of proper usage-type codes. Before I file a bug with the " + "export vendor, can you confirm these really aren't valid AWS SKUs?" + ), + "RSKU_AWS_BATCH9": ( + "Three rows in our cost export look totally malformed to me — \"just some random billing " + "text\", \"###invalid###\", and \"12345-not-a-sku\" — none of them look like real AWS " + "usage-type codes. Can you try pricing them and tell me what's going on?" + ), + "RSKU_GCP_BATCH7": ( + "Our finance team pasted these into the GCP cost spreadsheet as SKU references but they " + "don't look like real SKU IDs to me: not-a-real-sku and gcp-fake-id. Can you check what " + "they cost?" + ), + "RSKU_GCP_BATCH8": ( + "Someone hand-typed these SKU references into our GCP billing tracker: gcp-fake-id and " + "???. Can you tell me what those bill at?" + ), + "RSKU_GCP_BATCH9": ( + "I've got three garbled entries in a GCP billing export column that's supposed to hold " + "SKU IDs: not-a-real-sku, ???, and gcp-fake-id. What are their prices?" + ), + "RSKU_AZURE_BATCH7": ( + "Our billing export tool spat out a couple of garbled meter ID values for the eastus " + "region: 'not-a-guid' and 'azure-meter-xyz'. Can you check if either of those actually " + "resolves to anything priced?" + ), + "RSKU_AZURE_BATCH8": ( + "Two of the meter ID fields in our eastus Azure export got mangled somehow: " + "'azure-meter-xyz' and '!!!bad!!!'. Can you look these up and tell me what's going on?" + ), + "RSKU_AZURE_BATCH9": ( + "I've got three meter ID values from an Azure eastus export that all look wrong to me: " + "'not-a-guid', 'azure-meter-xyz', and '!!!bad!!!'. Can you check whether any of these " + "actually price out to something real?" + ), + # ----------------------------------------------------------------------- + # Raw-SKU lookup — batch mixed outcomes (RSKU) + # ----------------------------------------------------------------------- + "RSKU_AWS_BATCH10": ( + "I've got a batch of five weird line items from this month's Cost and Usage Report and " + "I want to reconcile all of them at once: BoxUsage:c8g.xlarge, BoxUsage:zz99.999xlarge, " + "BoxUsage:m6a.8xlarge, \"just some random billing text\", and " + "CAN1-BoxUsage:totallyfake.4xlarge, all us-east-1. Can you price out whichever of these " + "are real and flag anything that isn't?" + ), + "RSKU_GCP_BATCH10": ( + "My GCP Cloud Billing export for us-central1 has three SKU IDs I need priced all at " + "once: 77F8-D8AF-3CCE, 0000-0000-0000, and gcp-fake-id. Can you look up all three and " + "tell me which ones actually resolve?" + ), + "RSKU_AZURE_BATCH10": ( + "I need to reconcile three meter IDs from our Azure eastus export in one go: " + "3da19ca3-6007-4a29-89ea-cab10c2010ed, 00000000-0000-0000-0000-000000000000, and " + "'!!!bad!!!'. Can you check all three and tell me which ones are actually billable and " + "at what rate?" + ), + # ----------------------------------------------------------------------- + # Raw-SKU lookup — Bill of Materials (RSKU) + # ----------------------------------------------------------------------- + "RSKU_AWS_BOM1": ( + "My AWS Cost and Usage Report shows a cluster made up entirely of BoxUsage:c8g.xlarge " + "line items across three different node groups — 4 of them, 2 of them, and 6 of them, " + "all in us-east-1. Can you total up what this whole fleet costs me per month?" + ), + "RSKU_AWS_BOM2": ( + "I'm reconciling my AWS bill for a small web tier: the CUR shows 3x BoxUsage:c8g.xlarge " + "for the app servers, plus we're also provisioning 500GB of gp3 EBS storage for the " + "shared volume, all in us-east-1. What's the total monthly cost for this stack?" + ), + "RSKU_AWS_BOM3": ( + "We're deciding where to place a pair of m6a.8xlarge instances — the CUR usage type is " + "BoxUsage:m6a.8xlarge. Can you compare the monthly cost of running 2 of these in " + "us-east-1 versus eu-west-1 and tell me how much cheaper or more expensive eu-west-1 " + "is?" + ), + "RSKU_AWS_BOM4": ( + "I've got a batch-processing job that only runs 200 hours a month (not 24/7) using 5x " + "BoxUsage:c8g.xlarge instances in us-east-1. What would that actually cost me monthly " + "given the reduced runtime?" + ), + "RSKU_AWS_BOM5": ( + "My billing export lists two compute line items I need to estimate together: 2x " + "BoxUsage:c7i.2xlarge and 1x BoxUsage:z9.fake, both in us-east-1. Can you total up what " + "this stack costs per month, and flag anything you can't price?" + ), + "RSKU_AWS_BOM6": ( + "I'm sizing out a small stack: 2x BoxUsage:c8g.xlarge for the app tier, plus 1x " + "InstanceUsage:db.r6g.large for the database, both from the us-east-1 CUR. Does this " + "whole thing estimate cleanly, or is one of these line items going to need more info " + "from me?" + ), + "RSKU_AWS_BOM7": ( + "For a burst-capacity plan I want to compare 3x BoxUsage:m6a.8xlarge across us-east-1, " + "us-west-2, and eu-west-1 — which region comes out cheapest for this instance type and " + "by how much per month?" + ), + "RSKU_GCP_BOM1": ( + "My GCP Cloud Billing export has two Cloud KMS line items I want folded into a monthly " + "estimate, both in us-central1: skuId 77F8-D8AF-3CCE (active HSM symmetric key versions " + "for our Autokey setup) and skuId 88D6-F2EE-C781 (HSM symmetric crypto operations for " + "Autokey). Staging runs about 60 key versions and 8,000 crypto operations a month; " + "production runs about 400 key versions and 60,000 crypto operations a month. Can you " + "build a combined monthly cost estimate across both environments?" + ), + "RSKU_GCP_BOM2": ( + "I'm putting together a monthly estimate for a small stack in us-central1: 3 " + "n2-standard-4 Compute Engine instances running 24/7, plus the external IP charge for " + "those same 3 VMs which shows up in my billing export as skuId C054-7F72-A02E, plus " + "200GB of pd-ssd persistent disk. What's the total monthly cost?" + ), + "RSKU_GCP_BOM3": ( + "I have a Compute Engine external IP charge in my billing export — skuId C054-7F72-A02E " + "— for 5 VMs running 24/7, and I'm deciding which region to deploy in. Can you compare " + "the monthly cost of just that external IP charge across us-central1, europe-west4, and " + "asia-southeast1?" + ), + "RSKU_GCP_BOM4": ( + "My dev team spins up 8 Compute Engine VMs with external IPs, but only during work " + "hours — about 300 hours a month per VM, not 24/7. The billing export tags this as " + "skuId C054-7F72-A02E in us-east1. What would the external IP portion of my monthly " + "bill look like for those 8 dev VMs at 300 hours/month each?" + ), + "RSKU_GCP_BOM5": ( + "I'm building a monthly estimate for our GCP footprint in us-central1: 200 Cloud KMS " + "Autokey HSM key versions under skuId 77F8-D8AF-3CCE, plus another line item from the " + "billing export tagged skuId 1234-5678-90AB that I can't find documented anywhere. Can " + "you estimate the whole bundle and flag anything that doesn't resolve?" + ), + "RSKU_GCP_BOM6": ( + "Our Cloud KMS Autokey usage varies a lot by environment. The billing export shows " + "skuId 88D6-F2EE-C781 (HSM symmetric crypto operations for Autokey), all in " + "us-central1, at roughly 2,000 operations/month in dev, 9,500 in staging, and 45,000 in " + "production. Can you build a combined monthly cost estimate across those three " + "environments?" + ), + "RSKU_GCP_BOM7": ( + "We're deciding between us-central1 and europe-west4 for a new deployment: 2 " + "n2-standard-4 Compute Engine instances, 100GB of pd-balanced storage, and a Cloud KMS " + "Autokey line item from our billing export — skuId 77F8-D8AF-3CCE, 150 active HSM key " + "versions/month. Can you compare the total monthly cost of this whole stack across both " + "regions?" + ), + "RSKU_AZURE_BOM1": ( + "My Azure cost export lists two Virtual Machines line items under the same Dv3 family: " + "meterId 3da19ca3-6007-4a29-89ea-cab10c2010ed for 3 on-demand instances, and meterId " + "cf64c470-a287-5429-8dd7-756a877824a0 for 2 Spot instances, both in eastus running " + "24/7. Can you estimate my total monthly bill for this VM fleet?" + ), + "RSKU_AZURE_BOM2": ( + "I've got two rows in my Azure billing export for the same managed-disk meter, meterId " + "93a6a529-4f49-47cb-9b1e-db9e5f23263f, in eastus — one for 12 disks attached to " + "production and one for 2 disks in a dev environment. What's my total monthly disk " + "spend across both?" + ), + "RSKU_AZURE_BOM3": ( + "I'm reconciling a mixed-cloud bill. On the Azure side there are 2 D4s v3 VMs billed " + "under meterId 3da19ca3-6007-4a29-89ea-cab10c2010ed in eastus for our app servers, and " + "on the AWS side we're storing 500GB of gp3 log storage in us-east-1. What's the " + "combined monthly cost of that stack?" + ), + "RSKU_AZURE_BOM4": ( + "For our eastus environment I have 4 web-tier VMs I'd size as Standard_D4s_v3, plus 4 " + "attached P10 managed data disks billed under meterId " + "93a6a529-4f49-47cb-9b1e-db9e5f23263f — one disk per VM. Can you estimate the combined " + "monthly cost of the VMs and their attached disks?" + ), + "RSKU_AZURE_BOM5": ( + "We're deciding where to run 2 D4s v3 VMs — the CUR/billing meter we're currently " + "paying under is 3da19ca3-6007-4a29-89ea-cab10c2010ed, and today they run in eastus. " + "Can you compare our current eastus rate for those 2 VMs against running the same 2 VMs " + "in westus2 or centralus instead?" + ), + "RSKU_AZURE_BOM6": ( + "We run 3 D4s v3 Spot instances (meterId cf64c470-a287-5429-8dd7-756a877824a0) plus 3 " + "attached P10 managed data disks (meterId 93a6a529-4f49-47cb-9b1e-db9e5f23263f) in " + "eastus for a batch job — the VMs only run about 300 hours a month since the job shuts " + "down nights and weekends, but the disks stay attached and billed 24/7. What would that " + "combination cost us monthly?" + ), + "RSKU_AZURE_BOM7": ( + "My Azure export has 6 P10 managed disks in eastus under meterId " + "93a6a529-4f49-47cb-9b1e-db9e5f23263f, plus one more row with meterId " + "00000000-0000-0000-0000-badf00d00000 that I can't identify. Can you estimate my " + "monthly disk spend and flag anything you can't price?" + ), + # ----------------------------------------------------------------------- + # Raw-SKU lookup — protocol edge cases (RSKU) + # ----------------------------------------------------------------------- + "RSKU_ERR1": ( + "My AWS Cost and Usage Report has a line item for BoxUsage:c8g.xlarge but I forgot to " + "note which region it's billed in — can you tell me what that usage type costs?" + ), + "RSKU_ERR2": ( + "I'm about to reconcile a stack of AWS usage-type codes from this month's billing " + "export, but I haven't pulled the actual list together yet — can you get the batch SKU " + "pricing check ready to go so I can just hand you the codes in a minute?" + ), + "RSKU_ERR3": ( + "I moved one workload over to Oracle Cloud (OCI) and my OCI bill lists a line item by " + "its raw SKU code — can you look up its current rate the same way you did for my AWS " + "usage-type codes?" + ), + "RSKU_ERR4": ( + "My monthly AWS Cost and Usage Report export lists these usage types, all billed in " + "us-east-1 — can you price all of these in one shot: BoxUsage:x1.large, " + "BoxUsage:x2.large, BoxUsage:x3.large, BoxUsage:x4.large, BoxUsage:x5.large, " + "BoxUsage:x6.large, BoxUsage:x7.large, BoxUsage:x8.large, BoxUsage:x9.large, " + "BoxUsage:x10.large, BoxUsage:x11.large, BoxUsage:x12.large, BoxUsage:x13.large, " + "BoxUsage:x14.large, BoxUsage:x15.large, BoxUsage:x16.large, BoxUsage:x17.large, " + "BoxUsage:x18.large, BoxUsage:x19.large, BoxUsage:x20.large, BoxUsage:x21.large, " + "BoxUsage:x22.large, BoxUsage:x23.large, BoxUsage:x24.large, BoxUsage:x25.large, " + "BoxUsage:x26.large, BoxUsage:x27.large, BoxUsage:x28.large, BoxUsage:x29.large, " + "BoxUsage:x30.large?" + ), + "RSKU_ERR5": ( + "I have a line item on my AWS bill but the usage type column is blank — can you still " + "figure out what it costs?" + ), } From e5e98ac4260639f59853c50577a64b7fa524acfa Mon Sep 17 00:00:00 2001 From: x7even <38901965+x7even@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:57:07 +0000 Subject: [PATCH 8/9] fix(bom): require explicit provider for raw-SKU BoM items resolveBOMSKUItem (bom.go) silently defaulted a missing provider to "aws", risking misrouted lookups since BoM calls routinely mix items from multiple providers in one request. Now returns a clear error instead. compare_bom_regions.go's independent item-partitioning loop gets the matching fix, using stringItemField so a non-string provider value is reported as a type error rather than folded into "missing", and a shared notSupportedEntry helper plus rawSKUProviderRequiredHint constant to keep the two call sites' messages from drifting apart. Also updates the stale field-level items schema description (server.go and tools-snapshot.json) that still said "provider aws or gcp" and omitted the required-provider behavior, and strengthens the regression test to reuse the existing makeComputePrice fixture helper. --- opencloudcosts-go/internal/server/server.go | 8 +- opencloudcosts-go/internal/tools/bom.go | 17 +++- opencloudcosts-go/internal/tools/bom_test.go | 89 +++++++++++++++++++ .../internal/tools/compare_bom_regions.go | 72 ++++++++++----- .../tools/compare_bom_regions_test.go | 39 +++++++- opencloudcosts-go/schemas/tools-snapshot.json | 8 +- 6 files changed, 198 insertions(+), 35 deletions(-) diff --git a/opencloudcosts-go/internal/server/server.go b/opencloudcosts-go/internal/server/server.go index 2884445..7b6b9b8 100644 --- a/opencloudcosts-go/internal/server/server.go +++ b/opencloudcosts-go/internal/server/server.go @@ -473,7 +473,7 @@ const ( schemaCompareBOMRegions = `{ "properties": { "items": { - "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, provider aws or gcp) — see tool description.", + "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, provider — REQUIRED for raw-SKU items: aws, gcp, or azure) — see tool description.", "items": { "additionalProperties": true, "type": "object" @@ -585,7 +585,7 @@ const ( schemaEstimateBOM = `{ "properties": { "items": { - "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, provider aws or gcp) — see tool description.", + "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, provider — REQUIRED for raw-SKU items: aws, gcp, or azure) — see tool description.", "items": { "additionalProperties": true, "type": "object" @@ -3079,7 +3079,7 @@ const ( descDescribeCatalog = "\n Discover what each provider supports and how to call get_price.\n\n - No args → full support matrix across all configured providers.\n - provider only → all domains/services for that provider.\n - provider + domain [+ service] → targeted guidance with required_fields,\n supported_terms, filter_hints, and a ready-to-use example_invocation\n you can pass directly to get_price.\n\n Use this before get_price when unsure of exact field names or values.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\". Empty = all providers.\n domain: Domain — \"compute\", \"storage\", \"database\", \"ai\", \"container\",\n \"serverless\", \"analytics\", \"network\", \"observability\". Empty = all.\n service: Service — e.g. \"bedrock\", \"rds\", \"gke\", \"bigquery\". Empty = all.\n " - descCompareBOMRegions = "\n Compare a Bill of Materials' total monthly cost across multiple regions.\n\n v1 scope: PricingSpec-dict items are AWS-only. Each item is an open PricingSpec dict, same\n shape as estimate_bom's items (provider, domain, resource_type/region/etc, plus\n quantity/hours_per_month/size_gb/description) — or a raw-SKU dict (sku, region, plus\n optional service/operation/product_family) for a CUR usage-type/SKU string (provider=\"aws\"\n or omitted), a GCP Cloud Billing Catalog skuId string (provider=\"gcp\"; operation/\n product_family are ignored for GCP), or an Azure Retail Prices API meterId string\n (provider=\"azure\"; operation is ignored, product_family has Azure-specific meaning — see\n get_price_by_sku). The region field on each item is overridden per comparison — pass any\n region in the item dicts. A region's region_name is only populated from the region-code\n display maps when every resolvable item in the call shares one provider; a mixed-provider\n call (e.g. an AWS item and a GCP item together) falls back to the bare region code instead\n of guessing whose naming applies.\n Weighting and a providers filter are not supported yet. Unsupported items\n (a PricingSpec-dict item naming a non-AWS provider, or a raw-SKU item naming a provider\n other than aws/gcp/azure) are reported once under \"not_supported\" rather than guessed or\n dropped silently; full GCP/Azure PricingSpec-dict support is tracked separately.\n\n Returns regions[] sorted cheapest-first, each with total_monthly, the\n resolved line_items, and any per-item errors. Optionally shows delta vs\n a baseline region.\n\n Args:\n items: List of PricingSpec dicts (same shape as estimate_bom, AWS-only) — or\n raw-SKU dicts (sku, region, plus optional service/operation/\n product_family), provider \"aws\" (default), \"gcp\", or \"azure\". See estimate_bom\n for full item format.\n regions: List of region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\"].\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n " + descCompareBOMRegions = "\n Compare a Bill of Materials' total monthly cost across multiple regions.\n\n v1 scope: PricingSpec-dict items are AWS-only. Each item is an open PricingSpec dict, same\n shape as estimate_bom's items (provider, domain, resource_type/region/etc, plus\n quantity/hours_per_month/size_gb/description) — or a raw-SKU dict (sku, region, provider,\n plus optional service/operation/product_family) for a CUR usage-type/SKU string\n (provider=\"aws\"), a GCP Cloud Billing Catalog skuId string (provider=\"gcp\"; operation/\n product_family are ignored for GCP), or an Azure Retail Prices API meterId string\n (provider=\"azure\"; operation is ignored, product_family has Azure-specific meaning — see\n get_price_by_sku). Unlike get_price_by_sku, provider is REQUIRED on raw-SKU items here (no\n default) — a single call commonly compares items from different providers across the same\n regions, so a missing provider is reported once under \"not_supported\" (see below) rather\n than guessed. The region field on each item is overridden per comparison — pass any\n region in the item dicts. A region's region_name is only populated from the region-code\n display maps when every resolvable item in the call shares one provider; a mixed-provider\n call (e.g. an AWS item and a GCP item together) falls back to the bare region code instead\n of guessing whose naming applies.\n Weighting and a providers filter are not supported yet. Unsupported items\n (a PricingSpec-dict item naming a non-AWS provider, or a raw-SKU item naming a provider\n other than aws/gcp/azure, or a raw-SKU item with no provider at all) are reported once\n under \"not_supported\" rather than guessed or dropped silently; full GCP/Azure\n PricingSpec-dict support is tracked separately.\n\n Returns regions[] sorted cheapest-first, each with total_monthly, the\n resolved line_items, and any per-item errors. Optionally shows delta vs\n a baseline region.\n\n Args:\n items: List of PricingSpec dicts (same shape as estimate_bom, AWS-only) — or\n raw-SKU dicts (sku, region, plus required provider \"aws\", \"gcp\", or \"azure\",\n plus optional service/operation/product_family). See estimate_bom\n for full item format.\n regions: List of region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\"].\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n " descGetCoverage = "\n Report which domains/services this server actually covers, per provider.\n\n v1 scope: structural coverage from the catalog only — each domain is\n reported as \"catalog\" (with its known services) unless the provider\n has no entry for it at all. This does NOT fan out a live get_price call\n per region — whether a specific region's live price is a real catalog\n rate or a degraded fallback constant is only observable by calling\n get_price for that spec and checking its \"fallback\" field, since that\n is a live fetch outcome rather than a fixed property of the catalog.\n\n Use this to answer \"what does this server know about\" before trial-\n and-error against describe_catalog and individual get_price calls.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\". Empty = all\n configured providers.\n " @@ -3091,7 +3091,7 @@ const ( descWarmCache = "\n Pre-populate the pricing cache for a provider before a large sweep (e.g. a\n multi-region compare_prices or get_prices_batch call), so that sweep hits a warm\n cache instead of paying fetch latency on every combination.\n\n Resolves each requested service to its catalog example_invocation (the same data\n describe_catalog returns) and fans the resulting spec x region combinations out\n concurrently, mirroring the compare_prices/get_prices_batch fan-out pattern.\n\n Args:\n provider: Cloud provider — \"aws\", \"gcp\", or \"azure\"\n regions: List of region codes to warm, e.g. [\"us-east-1\", \"eu-west-1\"]\n services: Optional list of service names or describe_catalog keys, e.g.\n [\"ec2\", \"rds\", \"compute/fargate\"]. Omit to warm every service the\n provider's catalog has an example invocation for.\n " - descEstimateBOM = "\n Use this tool for total infrastructure cost, TCO, monthly spend for a multi-resource\n stack, or cost comparison between architectures.\n\n Handles compute + storage + database + AI together in a single call — do NOT call\n get_price individually for multi-resource questions; use this tool instead.\n\n Returns per-item and total monthly/annual costs with real public pricing data,\n plus a not_included list of supplementary costs (egress, load balancers, monitoring).\n These are SUPPLEMENTARY — only price them if the user asked for TCO; for most\n questions just note 'additional costs may apply'.\n\n Each item should be a PricingSpec dict PLUS a quantity field:\n - provider: \"aws\" | \"gcp\" | \"azure\"\n - domain: \"compute\" | \"storage\" | \"database\" | \"ai\" | ...\n - region: region code\n - quantity: number of units (default 1)\n - hours_per_month: hours/month for compute (default 730 = always-on)\n - description: optional label for this line item\n Plus domain-specific fields (see get_price or describe_catalog for details).\n\n An item may instead be a raw-SKU dict: {\"sku\": \"...\",\n \"region\": \"us-east-1\", \"quantity\": 3} — the same raw CUR usage-type/SKU string (provider\n \"aws\", default), GCP Cloud Billing Catalog skuId string (provider \"gcp\"), or Azure Retail\n Prices API meterId string (provider \"azure\") get_price_by_sku resolves, optionally with\n service/operation/product_family hints to disambiguate (operation is AWS-only, ignored for\n provider \"gcp\"/\"azure\"; product_family is AWS-only for the productFamily-matching behavior\n described in get_price_by_sku, but carries different Azure-specific meaning — see\n get_price_by_sku — for provider \"azure\", and is ignored for provider \"gcp\"). A GCP SKU with\n usage-volume tiers is costed at the tier matching this item's quantity.\n\n Examples:\n Compute + database + storage on AWS:\n [\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\", \"quantity\": 3},\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r6g.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"},\n {\"provider\": \"aws\", \"domain\": \"storage\", \"storage_type\": \"gp3\", \"size_gb\": 500, \"region\": \"us-east-1\"}\n ]\n\n Mixed cloud:\n [\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"quantity\": 2},\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\", \"quantity\": 1}\n ]\n " + descEstimateBOM = "\n Use this tool for total infrastructure cost, TCO, monthly spend for a multi-resource\n stack, or cost comparison between architectures.\n\n Handles compute + storage + database + AI together in a single call — do NOT call\n get_price individually for multi-resource questions; use this tool instead.\n\n Returns per-item and total monthly/annual costs with real public pricing data,\n plus a not_included list of supplementary costs (egress, load balancers, monitoring).\n These are SUPPLEMENTARY — only price them if the user asked for TCO; for most\n questions just note 'additional costs may apply'.\n\n Each item should be a PricingSpec dict PLUS a quantity field:\n - provider: \"aws\" | \"gcp\" | \"azure\"\n - domain: \"compute\" | \"storage\" | \"database\" | \"ai\" | ...\n - region: region code\n - quantity: number of units (default 1)\n - hours_per_month: hours/month for compute (default 730 = always-on)\n - description: optional label for this line item\n Plus domain-specific fields (see get_price or describe_catalog for details).\n\n An item may instead be a raw-SKU dict: {\"sku\": \"...\", \"provider\": \"aws\",\n \"region\": \"us-east-1\", \"quantity\": 3} — the same raw CUR usage-type/SKU string (provider\n \"aws\"), GCP Cloud Billing Catalog skuId string (provider \"gcp\"), or Azure Retail\n Prices API meterId string (provider \"azure\") get_price_by_sku resolves. Unlike\n get_price_by_sku, provider is REQUIRED here (no default) — a BoM commonly mixes items from\n different providers in one call, so a missing provider is rejected with a clear error\n rather than guessed. Optionally add service/operation/product_family hints to disambiguate\n (operation is AWS-only, ignored for provider \"gcp\"/\"azure\"; product_family is AWS-only for\n the productFamily-matching behavior described in get_price_by_sku, but carries different\n Azure-specific meaning — see get_price_by_sku — for provider \"azure\", and is ignored for\n provider \"gcp\"). A GCP SKU with usage-volume tiers is costed at the tier matching this\n item's quantity.\n\n Examples:\n Compute + database + storage on AWS:\n [\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\", \"quantity\": 3},\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r6g.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"},\n {\"provider\": \"aws\", \"domain\": \"storage\", \"storage_type\": \"gp3\", \"size_gb\": 500, \"region\": \"us-east-1\"}\n ]\n\n Mixed cloud:\n [\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"quantity\": 2},\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\", \"quantity\": 1}\n ]\n " descEstimateUnitEconomics = "\n Estimate per-unit economics (cost per user, per request, per transaction) given\n a Bill of Materials and expected monthly usage volume.\n\n Args:\n items: Same format as estimate_bom — list of cloud resource PricingSpec dicts\n plus quantity field. See estimate_bom for full item format.\n units_per_month: Monthly volume being measured (e.g. 10000 users)\n unit_label: What the unit represents — \"user\", \"request\", \"transaction\", etc.\n " diff --git a/opencloudcosts-go/internal/tools/bom.go b/opencloudcosts-go/internal/tools/bom.go index 7809b0f..09e9c9c 100644 --- a/opencloudcosts-go/internal/tools/bom.go +++ b/opencloudcosts-go/internal/tools/bom.go @@ -500,6 +500,12 @@ func processBOMItems( return lineItems, errs } +// rawSKUProviderRequiredHint is the shared guidance clause used both by +// resolveBOMSKUItem below and by HandleCompareBOMRegions's item-partitioning +// loop (compare_bom_regions.go) when a raw-SKU BoM item omits provider, so +// the two call sites' messages can't drift independently. +const rawSKUProviderRequiredHint = `specify "provider": "aws", "gcp", or "azure"` + // -------------------------------------------------------------------------- // resolveBOMSKUItem resolves a raw-SKU BoM line item (issue #31, RC3-004) — // mirrors resolveSKUPriceEntry's (sku_lookup.go) error-unwrapping and @@ -529,9 +535,14 @@ func resolveBOMSKUItem( return bomLineItem{}, errMsg } if providerName == "" { - // Mirrors HandleGetPriceBySKU's default: raw usage-type/SKU strings - // are an AWS CUR concept, so an absent provider means "aws". - providerName = "aws" + // Unlike HandleGetPriceBySKU (a single-provider-per-call tool where + // an absent provider safely defaults to "aws"), BoM calls routinely + // mix items from different providers in one request, so a silent + // default here would misroute non-AWS SKUs into AWS's usage-type + // resolver. Require an explicit provider instead. + return bomLineItem{}, fmt.Sprintf( + "%s: provider is required for raw-SKU BoM items (sku %q) — %s", + label, sku, rawSKUProviderRequiredHint) } lookupP, errOut := resolveSKULookupProviderFromMap(provs, providerName, "raw-SKU BoM items") diff --git a/opencloudcosts-go/internal/tools/bom_test.go b/opencloudcosts-go/internal/tools/bom_test.go index faad129..31bfadf 100644 --- a/opencloudcosts-go/internal/tools/bom_test.go +++ b/opencloudcosts-go/internal/tools/bom_test.go @@ -1274,6 +1274,7 @@ func TestEstimateBOM_RawSKUItem(t *testing.T) { items := []map[string]any{ { "sku": "BoxUsage:r6id.24xlarge", + "provider": "aws", "service": "AmazonEC2", "region": "us-east-1", "quantity": float64(1), @@ -1338,12 +1339,14 @@ func TestEstimateBOM_RawSKUItem_PartialFailureNoMapping(t *testing.T) { items := []map[string]any{ { "sku": "BoxUsage:r6id.24xlarge", + "provider": "aws", "service": "AmazonEC2", "region": "us-east-1", "quantity": float64(1), }, { "sku": "BoxUsage:doesnotexist", + "provider": "aws", "service": "AmazonEC2", "region": "us-east-1", "quantity": float64(1), @@ -1417,6 +1420,7 @@ func TestEstimateBOM_RawSKUItemTrimsWhitespace(t *testing.T) { items := []map[string]any{ { "sku": " BoxUsage:m5.xlarge ", + "provider": "aws", "service": "AmazonEC2", "region": "us-east-1", "quantity": float64(1), @@ -1490,6 +1494,7 @@ func TestEstimateBOM_RawSKUItemNonStringOperationRejected(t *testing.T) { items := []map[string]any{ { "sku": "BoxUsage:m5.xlarge", + "provider": "aws", "service": "AmazonEC2", "region": "us-east-1", "operation": []any{"CreateDBInstance"}, @@ -1538,6 +1543,7 @@ func TestEstimateBOM_RawSKUItemAdvisoriesIncluded(t *testing.T) { items := []map[string]any{ { "sku": "BoxUsage:m5.xlarge", + "provider": "aws", "service": "AmazonEC2", "region": "us-east-1", "quantity": float64(1), @@ -1570,6 +1576,89 @@ func TestEstimateBOM_RawSKUItemAdvisoriesIncluded(t *testing.T) { } } +// TestEstimateBOM_RawSKUItemMissingProviderRequiresExplicitProvider is the +// regression test for the bug where a raw-SKU BoM item omitting "provider" +// was silently defaulted to "aws" (fine for get_price_by_sku's single-SKU, +// single-provider-per-call contract, but wrong for estimate_bom, which +// routinely mixes items from different providers in one call). A BoM with +// two items — one explicit non-aws (azure) domain-based item, and one +// raw-SKU item with a GUID-style SKU (the Azure meterId shape) but no +// "provider" field — must now surface a clear "provider is required" error +// for the second item, and the first (valid) item must still resolve and +// contribute to totals: one bad item must not take down the whole BoM +// (processBOMItems' existing partial-success contract, unchanged by this +// fix). The negative assertions below (no "servicecode"/"AWS" wording) check +// the new message's own wording only — resolveSKULookupProviderFromMap only +// recognizes concrete provider implementations (see its type switch in +// sku_lookup.go), so this mockProvider-based test can't reach the old code's +// deeper "could not infer AWS servicecode" error to prove non-regression +// directly; the "provider is required" assertion is what actually +// distinguishes old vs. new behavior here. +func TestEstimateBOM_RawSKUItemMissingProviderRequiresExplicitProvider(t *testing.T) { + pvdr := &mockProvider{ + name: "azure", + defaultRegion: "eastus", + supportsFunc: func(_ models.PricingDomain, _ string) bool { return true }, + getPriceFunc: func(_ context.Context, spec models.PricingSpec) (*models.PricingResult, error) { + price := makeComputePrice("azure", "eastus", "D4s_v3", 0.192) + return &models.PricingResult{PublicPrices: []models.NormalizedPrice{price}}, nil + }, + } + h := tools.New(map[string]tools.Provider{"azure": pvdr}) + + items := []map[string]any{ + { + "provider": "azure", + "domain": "compute", + "resource_type": "D4s_v3", + "region": "eastus", + "quantity": float64(4), + }, + { + // A GUID-style SKU (the shape of an Azure Retail Prices API + // meterId) with no "provider" field — the exact regression + // scenario from the bug report. + "sku": "93a6a529-0000-0000-0000-000000000000", + "region": "eastus", + "quantity": float64(4), + }, + } + resp := callEstimateBOM(t, h, items) + + if topErr, ok := resp["error"]; ok { + t.Fatalf("expected no top-level error (the valid item should still resolve), got: %v", topErr) + } + + lineItems, ok := resp["line_items"].([]any) + if !ok || len(lineItems) != 1 { + t.Fatalf("expected 1 successful line item (the azure domain item), got: %v", resp["line_items"]) + } + + errsVal := resp["errors"] + errs, ok := errsVal.([]any) + if !ok || len(errs) != 1 { + t.Fatalf("expected exactly 1 per-item error for the missing-provider raw-SKU item, got: %v", errsVal) + } + errStr, _ := errs[0].(string) + if !strings.Contains(errStr, "provider is required") { + t.Errorf("expected a clear 'provider is required' error, got %q", errStr) + } + if strings.Contains(errStr, "servicecode") || strings.Contains(errStr, "AWS") { + t.Errorf("expected no misleading AWS-servicecode language in the error, got %q", errStr) + } + + totals, ok := resp["totals"].(map[string]any) + if !ok { + t.Fatalf("expected totals in response, got %v", resp["totals"]) + } + monthly := totals["monthly"].(map[string]any) + // Only the resolvable azure domain item contributes: + // 0.192/hr * 730 hrs/mo * quantity 4 = $560.64/mo. + if monthly["display"] != "$560.64/mo" { + t.Errorf("expected total monthly $560.64/mo (missing-provider item excluded), got %v", monthly["display"]) + } +} + // -------------------------------------------------------------------------- // GCP raw-SKU BoM items (RC3-015) // -------------------------------------------------------------------------- diff --git a/opencloudcosts-go/internal/tools/compare_bom_regions.go b/opencloudcosts-go/internal/tools/compare_bom_regions.go index 5e18806..646aab1 100644 --- a/opencloudcosts-go/internal/tools/compare_bom_regions.go +++ b/opencloudcosts-go/internal/tools/compare_bom_regions.go @@ -37,6 +37,19 @@ import ( // resolves to real prices in v1. const compareBOMRegionsV1Provider = "aws" +// notSupportedEntry builds one "not_supported" entry for the partitioning +// loop below — factored out since the loop appends this exact four-key shape +// for three distinct rejection reasons (missing provider, unsupported raw-SKU +// provider, unsupported PricingSpec-dict provider). +func notSupportedEntry(label, provider, reason string) map[string]any { + return map[string]any{ + "item": label, + "provider": provider, + "source": "not_supported", + "reason": reason, + } +} + // CompareBOMRegionsInput is the typed input for the compare_bom_regions tool. type CompareBOMRegionsInput struct { Items []map[string]any `json:"items"` @@ -72,37 +85,46 @@ func (h *Handler) HandleCompareBOMRegions( for idx, item := range in.Items { label := fmt.Sprintf("Item %d", idx+1) - // Raw-SKU items are implicitly AWS (same default get_price_by_sku - // applies to a missing provider) and also accept an explicit - // provider=="gcp" (RC3-015) or provider=="azure" — resolveBOMSKUItem - // resolves any of these providers generically. An item naming any - // other provider is routed to notSupported here, exactly like any - // other unsupported item, rather than being rejected once per region - // inside processBOMItems below. - if _, ok := rawBOMSKU(item); ok { - pvdrName, hasPvdr := item["provider"].(string) - if !hasPvdr || pvdrName == "" || strings.EqualFold(pvdrName, compareBOMRegionsV1Provider) || + // Raw-SKU items require an explicit provider=="aws", "gcp" (RC3-015), + // or "azure" — resolveBOMSKUItem (bom.go) resolves any of these + // generically, but no longer defaults a missing provider to "aws": + // compare_bom_regions routinely mixes items from different providers + // in one call, so a silent default risks misrouting a non-AWS SKU + // into AWS's usage-type resolver. An item with a missing or + // unsupported provider is routed to notSupported here, exactly once, + // since the provider problem does not vary per region — rather than + // being rejected identically inside processBOMItems on every region + // iteration below. + if sku, ok := rawBOMSKU(item); ok { + // stringItemField (bom.go), not a bare type assertion, so a + // provider value that is present but not a string (e.g. a + // number) is reported as a type error instead of being folded + // into the same "provider is required" message as a genuinely + // absent field. + pvdrName, typeErr := stringItemField(item, "provider", label, sku) + if typeErr != "" { + notSupported = append(notSupported, notSupportedEntry(label, "", typeErr)) + continue + } + if pvdrName == "" { + notSupported = append(notSupported, notSupportedEntry(label, pvdrName, + fmt.Sprintf("provider is required for raw-SKU BoM items — %s.", rawSKUProviderRequiredHint))) + continue + } + if strings.EqualFold(pvdrName, compareBOMRegionsV1Provider) || strings.EqualFold(pvdrName, "gcp") || strings.EqualFold(pvdrName, "azure") { resolvable = append(resolvable, item) continue } - notSupported = append(notSupported, map[string]any{ - "item": label, - "provider": pvdrName, - "source": "not_supported", - "reason": "compare_bom_regions raw-SKU items support aws, gcp, and azure providers only — this provider is not yet supported.", - }) + notSupported = append(notSupported, notSupportedEntry(label, pvdrName, + "compare_bom_regions raw-SKU items support aws, gcp, and azure providers only — this provider is not yet supported.")) continue } pvdrName, _ := item["provider"].(string) if strings.ToLower(pvdrName) != compareBOMRegionsV1Provider { - notSupported = append(notSupported, map[string]any{ - "item": label, - "provider": pvdrName, - "source": "not_supported", - "reason": "compare_bom_regions v1 is AWS-only (RC3-004) — this provider is not yet supported.", - }) + notSupported = append(notSupported, notSupportedEntry(label, pvdrName, + "compare_bom_regions v1 is AWS-only (RC3-004) — this provider is not yet supported.")) continue } resolvable = append(resolvable, item) @@ -124,7 +146,11 @@ func (h *Handler) HandleCompareBOMRegions( pvdrName, _ := item["provider"].(string) pvdrName = strings.ToLower(pvdrName) if pvdrName == "" { - pvdrName = compareBOMRegionsV1Provider // raw-SKU/PricingSpec-dict default + // Defensive fallback only: every item reaching resolvable now + // carries an explicit, validated provider (raw-SKU items require + // one per the partitioning above; PricingSpec-dict items already + // required one). This branch should be unreachable. + pvdrName = compareBOMRegionsV1Provider } resolvableProviders[pvdrName] = struct{}{} } diff --git a/opencloudcosts-go/internal/tools/compare_bom_regions_test.go b/opencloudcosts-go/internal/tools/compare_bom_regions_test.go index 2f723b6..5d50c1f 100644 --- a/opencloudcosts-go/internal/tools/compare_bom_regions_test.go +++ b/opencloudcosts-go/internal/tools/compare_bom_regions_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "strings" "testing" "github.com/x7even/cloudcostsmcp/opencloudcosts-go/internal/config" @@ -190,7 +191,7 @@ func TestCompareBOMRegions_RawSKUItem(t *testing.T) { resp := callCompareBOMRegions(t, h, tools.CompareBOMRegionsInput{ Items: []map[string]any{ - {"sku": "BoxUsage:r6id.24xlarge", "service": "AmazonEC2", "quantity": float64(2)}, + {"sku": "BoxUsage:r6id.24xlarge", "provider": "aws", "service": "AmazonEC2", "quantity": float64(2)}, }, Regions: []string{"us-east-1", "us-west-2"}, }) @@ -268,6 +269,42 @@ func TestCompareBOMRegions_RawSKUNonAWSProviderReportedOnce(t *testing.T) { } } +// TestCompareBOMRegions_RawSKUMissingProviderReportedOnce verifies a raw-SKU +// item that omits "provider" entirely (rather than naming an explicit +// unsupported one, as in TestCompareBOMRegions_RawSKUNonAWSProviderReportedOnce +// above) is also routed to not_supported exactly once at the top level — +// not silently defaulted to "aws" and not re-derived/re-errored once per +// compared region. +func TestCompareBOMRegions_RawSKUMissingProviderReportedOnce(t *testing.T) { + pvdr := newRegionPricedProvider(map[string]float64{"us-east-1": 0.192, "us-west-2": 0.150}) + h := tools.New(map[string]tools.Provider{"aws": pvdr}) + + resp := callCompareBOMRegions(t, h, tools.CompareBOMRegionsInput{ + Items: []map[string]any{ + {"sku": "93a6a529-0000-0000-0000-000000000000", "region": "eastus"}, + }, + Regions: []string{"us-east-1", "us-west-2"}, + }) + + notSupported, ok := resp["not_supported"].([]any) + if !ok || len(notSupported) != 1 { + t.Fatalf("expected exactly 1 not_supported entry, got: %v", resp["not_supported"]) + } + entry := notSupported[0].(map[string]any) + reason, _ := entry["reason"].(string) + if !strings.Contains(reason, "provider is required") { + t.Errorf("expected a 'provider is required' reason, got %v", entry) + } + + regions := resp["regions"].([]any) + for _, r := range regions { + region := r.(map[string]any) + if errs, ok := region["errors"].([]any); ok && len(errs) > 0 { + t.Errorf("expected no per-region errors for the missing-provider raw-SKU item (should be reported once at top level), got: %v in region %v", errs, region["region"]) + } + } +} + // TestCompareBOMRegions_GCPRawSKUItem verifies a GCP raw-SKU BoM item // resolves per region against a real *gcpprovider.Provider — the GCP // counterpart to TestCompareBOMRegions_RawSKUItem above, added for RC3-015. diff --git a/opencloudcosts-go/schemas/tools-snapshot.json b/opencloudcosts-go/schemas/tools-snapshot.json index 5af61db..097751f 100644 --- a/opencloudcosts-go/schemas/tools-snapshot.json +++ b/opencloudcosts-go/schemas/tools-snapshot.json @@ -532,11 +532,11 @@ }, { "name": "estimate_bom", - "description": "\n Use this tool for total infrastructure cost, TCO, monthly spend for a multi-resource\n stack, or cost comparison between architectures.\n\n Handles compute + storage + database + AI together in a single call — do NOT call\n get_price individually for multi-resource questions; use this tool instead.\n\n Returns per-item and total monthly/annual costs with real public pricing data,\n plus a not_included list of supplementary costs (egress, load balancers, monitoring).\n These are SUPPLEMENTARY — only price them if the user asked for TCO; for most\n questions just note 'additional costs may apply'.\n\n Each item should be a PricingSpec dict PLUS a quantity field:\n - provider: \"aws\" | \"gcp\" | \"azure\"\n - domain: \"compute\" | \"storage\" | \"database\" | \"ai\" | ...\n - region: region code\n - quantity: number of units (default 1)\n - hours_per_month: hours/month for compute (default 730 = always-on)\n - description: optional label for this line item\n Plus domain-specific fields (see get_price or describe_catalog for details).\n\n An item may instead be a raw-SKU dict: {\"sku\": \"...\",\n \"region\": \"us-east-1\", \"quantity\": 3} — the same raw CUR usage-type/SKU string (provider\n \"aws\", default), GCP Cloud Billing Catalog skuId string (provider \"gcp\"), or Azure Retail\n Prices API meterId string (provider \"azure\") get_price_by_sku resolves, optionally with\n service/operation/product_family hints to disambiguate (operation is AWS-only, ignored for\n provider \"gcp\"/\"azure\"; product_family is AWS-only for the productFamily-matching behavior\n described in get_price_by_sku, but carries different Azure-specific meaning — see\n get_price_by_sku — for provider \"azure\", and is ignored for provider \"gcp\"). A GCP SKU with\n usage-volume tiers is costed at the tier matching this item's quantity.\n\n Examples:\n Compute + database + storage on AWS:\n [\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\", \"quantity\": 3},\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r6g.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"},\n {\"provider\": \"aws\", \"domain\": \"storage\", \"storage_type\": \"gp3\", \"size_gb\": 500, \"region\": \"us-east-1\"}\n ]\n\n Mixed cloud:\n [\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"quantity\": 2},\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\", \"quantity\": 1}\n ]\n ", + "description": "\n Use this tool for total infrastructure cost, TCO, monthly spend for a multi-resource\n stack, or cost comparison between architectures.\n\n Handles compute + storage + database + AI together in a single call — do NOT call\n get_price individually for multi-resource questions; use this tool instead.\n\n Returns per-item and total monthly/annual costs with real public pricing data,\n plus a not_included list of supplementary costs (egress, load balancers, monitoring).\n These are SUPPLEMENTARY — only price them if the user asked for TCO; for most\n questions just note 'additional costs may apply'.\n\n Each item should be a PricingSpec dict PLUS a quantity field:\n - provider: \"aws\" | \"gcp\" | \"azure\"\n - domain: \"compute\" | \"storage\" | \"database\" | \"ai\" | ...\n - region: region code\n - quantity: number of units (default 1)\n - hours_per_month: hours/month for compute (default 730 = always-on)\n - description: optional label for this line item\n Plus domain-specific fields (see get_price or describe_catalog for details).\n\n An item may instead be a raw-SKU dict: {\"sku\": \"...\", \"provider\": \"aws\",\n \"region\": \"us-east-1\", \"quantity\": 3} — the same raw CUR usage-type/SKU string (provider\n \"aws\"), GCP Cloud Billing Catalog skuId string (provider \"gcp\"), or Azure Retail\n Prices API meterId string (provider \"azure\") get_price_by_sku resolves. Unlike\n get_price_by_sku, provider is REQUIRED here (no default) — a BoM commonly mixes items from\n different providers in one call, so a missing provider is rejected with a clear error\n rather than guessed. Optionally add service/operation/product_family hints to disambiguate\n (operation is AWS-only, ignored for provider \"gcp\"/\"azure\"; product_family is AWS-only for\n the productFamily-matching behavior described in get_price_by_sku, but carries different\n Azure-specific meaning — see get_price_by_sku — for provider \"azure\", and is ignored for\n provider \"gcp\"). A GCP SKU with usage-volume tiers is costed at the tier matching this\n item's quantity.\n\n Examples:\n Compute + database + storage on AWS:\n [\n {\"provider\": \"aws\", \"domain\": \"compute\", \"resource_type\": \"m5.xlarge\", \"region\": \"us-east-1\", \"quantity\": 3},\n {\"provider\": \"aws\", \"domain\": \"database\", \"service\": \"rds\", \"resource_type\": \"db.r6g.large\", \"engine\": \"MySQL\", \"deployment\": \"single-az\", \"region\": \"us-east-1\"},\n {\"provider\": \"aws\", \"domain\": \"storage\", \"storage_type\": \"gp3\", \"size_gb\": 500, \"region\": \"us-east-1\"}\n ]\n\n Mixed cloud:\n [\n {\"provider\": \"gcp\", \"domain\": \"compute\", \"resource_type\": \"n1-standard-4\", \"region\": \"us-central1\", \"quantity\": 2},\n {\"provider\": \"azure\", \"domain\": \"compute\", \"resource_type\": \"Standard_D4s_v3\", \"region\": \"eastus\", \"quantity\": 1}\n ]\n ", "inputSchema": { "properties": { "items": { - "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, provider aws or gcp) — see tool description.", + "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, provider — REQUIRED for raw-SKU items: aws, gcp, or azure) — see tool description.", "items": { "additionalProperties": true, "type": "object" @@ -715,7 +715,7 @@ }, { "name": "compare_bom_regions", - "description": "\n Compare a Bill of Materials' total monthly cost across multiple regions.\n\n v1 scope: PricingSpec-dict items are AWS-only. Each item is an open PricingSpec dict, same\n shape as estimate_bom's items (provider, domain, resource_type/region/etc, plus\n quantity/hours_per_month/size_gb/description) — or a raw-SKU dict (sku, region, plus\n optional service/operation/product_family) for a CUR usage-type/SKU string (provider=\"aws\"\n or omitted), a GCP Cloud Billing Catalog skuId string (provider=\"gcp\"; operation/\n product_family are ignored for GCP), or an Azure Retail Prices API meterId string\n (provider=\"azure\"; operation is ignored, product_family has Azure-specific meaning — see\n get_price_by_sku). The region field on each item is overridden per comparison — pass any\n region in the item dicts. A region's region_name is only populated from the region-code\n display maps when every resolvable item in the call shares one provider; a mixed-provider\n call (e.g. an AWS item and a GCP item together) falls back to the bare region code instead\n of guessing whose naming applies.\n Weighting and a providers filter are not supported yet. Unsupported items\n (a PricingSpec-dict item naming a non-AWS provider, or a raw-SKU item naming a provider\n other than aws/gcp/azure) are reported once under \"not_supported\" rather than guessed or\n dropped silently; full GCP/Azure PricingSpec-dict support is tracked separately.\n\n Returns regions[] sorted cheapest-first, each with total_monthly, the\n resolved line_items, and any per-item errors. Optionally shows delta vs\n a baseline region.\n\n Args:\n items: List of PricingSpec dicts (same shape as estimate_bom, AWS-only) — or\n raw-SKU dicts (sku, region, plus optional service/operation/\n product_family), provider \"aws\" (default), \"gcp\", or \"azure\". See estimate_bom\n for full item format.\n regions: List of region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\"].\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n ", + "description": "\n Compare a Bill of Materials' total monthly cost across multiple regions.\n\n v1 scope: PricingSpec-dict items are AWS-only. Each item is an open PricingSpec dict, same\n shape as estimate_bom's items (provider, domain, resource_type/region/etc, plus\n quantity/hours_per_month/size_gb/description) — or a raw-SKU dict (sku, region, provider,\n plus optional service/operation/product_family) for a CUR usage-type/SKU string\n (provider=\"aws\"), a GCP Cloud Billing Catalog skuId string (provider=\"gcp\"; operation/\n product_family are ignored for GCP), or an Azure Retail Prices API meterId string\n (provider=\"azure\"; operation is ignored, product_family has Azure-specific meaning — see\n get_price_by_sku). Unlike get_price_by_sku, provider is REQUIRED on raw-SKU items here (no\n default) — a single call commonly compares items from different providers across the same\n regions, so a missing provider is reported once under \"not_supported\" (see below) rather\n than guessed. The region field on each item is overridden per comparison — pass any\n region in the item dicts. A region's region_name is only populated from the region-code\n display maps when every resolvable item in the call shares one provider; a mixed-provider\n call (e.g. an AWS item and a GCP item together) falls back to the bare region code instead\n of guessing whose naming applies.\n Weighting and a providers filter are not supported yet. Unsupported items\n (a PricingSpec-dict item naming a non-AWS provider, or a raw-SKU item naming a provider\n other than aws/gcp/azure, or a raw-SKU item with no provider at all) are reported once\n under \"not_supported\" rather than guessed or dropped silently; full GCP/Azure\n PricingSpec-dict support is tracked separately.\n\n Returns regions[] sorted cheapest-first, each with total_monthly, the\n resolved line_items, and any per-item errors. Optionally shows delta vs\n a baseline region.\n\n Args:\n items: List of PricingSpec dicts (same shape as estimate_bom, AWS-only) — or\n raw-SKU dicts (sku, region, plus required provider \"aws\", \"gcp\", or \"azure\",\n plus optional service/operation/product_family). See estimate_bom\n for full item format.\n regions: List of region codes to compare, e.g. [\"us-east-1\", \"eu-west-1\"].\n baseline_region: Optional region for delta comparison, e.g. \"us-east-1\".\n ", "inputSchema": { "properties": { "baseline_region": { @@ -724,7 +724,7 @@ "type": "string" }, "items": { - "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, provider aws or gcp) — see tool description.", + "description": "PricingSpec dicts, or raw-SKU dicts (sku, region, provider — REQUIRED for raw-SKU items: aws, gcp, or azure) — see tool description.", "items": { "additionalProperties": true, "type": "object" From 9b703d099ca7c142d508268f71319c3e31cdbdb1 Mon Sep 17 00:00:00 2001 From: x7even <38901965+x7even@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:07:40 +0000 Subject: [PATCH 9/9] chore(build): bump go toolchain to 1.25.12 to fix GO-2026-5856 go1.25.11's crypto/tls has an Encrypted Client Hello privacy leak (GO-2026-5856), flagged by CI's govulncheck step. Fixed upstream in go1.25.12. --- opencloudcosts-go/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opencloudcosts-go/go.mod b/opencloudcosts-go/go.mod index 6a122cf..74b41c4 100644 --- a/opencloudcosts-go/go.mod +++ b/opencloudcosts-go/go.mod @@ -1,6 +1,6 @@ module github.com/x7even/cloudcostsmcp/opencloudcosts-go -go 1.25.11 +go 1.25.12 require ( github.com/aws/aws-sdk-go-v2 v1.42.0