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

Merge commit 'ca98c342b5d33bc01164dcf1c50773e861f75d10' into feature/kubemodel

# Conflicts:
#	pkg/costmodel/costmodel_test.go
Sean Holcomb 6 дней назад
Родитель
Сommit
d41cca7209
51 измененных файлов с 1818 добавлено и 427 удалено
  1. 1 1
      .github/workflows/build-and-publish-develop.yml
  2. 1 1
      .github/workflows/build-and-publish-release.yml
  3. 5 4
      Tiltfile.opencost
  4. 98 0
      core/pkg/autocomplete/allocation/allocation_test.go
  5. 13 0
      core/pkg/autocomplete/allocation/parse.go
  6. 35 0
      core/pkg/autocomplete/allocation/route.go
  7. 11 0
      core/pkg/autocomplete/allocation/types.go
  8. 32 0
      core/pkg/autocomplete/allocation/validate.go
  9. 130 0
      core/pkg/autocomplete/asset/asset_test.go
  10. 16 0
      core/pkg/autocomplete/asset/parse.go
  11. 33 0
      core/pkg/autocomplete/asset/route.go
  12. 34 0
      core/pkg/autocomplete/asset/static.go
  13. 11 0
      core/pkg/autocomplete/asset/types.go
  14. 39 0
      core/pkg/autocomplete/asset/validate.go
  15. 79 0
      core/pkg/autocomplete/autocomplete_test.go
  16. 82 0
      core/pkg/autocomplete/cloudcost/cloudcost_test.go
  17. 13 0
      core/pkg/autocomplete/cloudcost/parse.go
  18. 11 0
      core/pkg/autocomplete/cloudcost/types.go
  19. 30 0
      core/pkg/autocomplete/cloudcost/validate.go
  20. 11 0
      core/pkg/autocomplete/errors.go
  21. 49 0
      core/pkg/autocomplete/label.go
  22. 37 0
      core/pkg/autocomplete/label_test.go
  23. 4 0
      core/pkg/autocomplete/limits.go
  24. 64 0
      core/pkg/autocomplete/normalize.go
  25. 86 0
      core/pkg/autocomplete/parse.go
  26. 22 0
      core/pkg/autocomplete/request.go
  27. 158 0
      core/pkg/autocomplete/request_test.go
  28. 76 0
      core/pkg/autocomplete/util.go
  29. 41 0
      pkg/allocation/autocomplete_parser_test.go
  30. 13 94
      pkg/allocation/autocompletequeryservice.go
  31. 23 16
      pkg/allocation/autocompletequeryservice_test.go
  32. 44 0
      pkg/asset/autocomplete_normalize_test.go
  33. 75 0
      pkg/asset/autocomplete_parser_test.go
  34. 14 0
      pkg/asset/autocomplete_static_test.go
  35. 24 94
      pkg/asset/autocompletequeryservice.go
  36. 9 8
      pkg/asset/autocompletequeryservice_test.go
  37. 33 6
      pkg/cloud/gcp/bigqueryconfiguration.go
  38. 58 2
      pkg/cloud/gcp/bigqueryconfiguration_test.go
  39. 28 6
      pkg/cloud/provider/customprovider.go
  40. 163 0
      pkg/cloud/provider/provider_test.go
  41. 0 8
      pkg/cloud/provider/providerconfig.go
  42. 9 8
      pkg/cloudcost/autocomplete_test.go
  43. 2 16
      pkg/cloudcost/querier.go
  44. 4 2
      pkg/cloudcost/queryservice.go
  45. 0 37
      pkg/cloudcost/queryservice_helper.go
  46. 6 4
      pkg/cloudcost/queryservice_helper_test.go
  47. 7 55
      pkg/cloudcost/repositoryquerier.go
  48. 22 53
      pkg/costmodel/autocomplete.go
  49. 8 8
      pkg/costmodel/cluster_helpers_test.go
  50. 49 0
      pkg/costmodel/costmodel_test.go
  51. 5 4
      pkg/mcp/server_test.go

+ 1 - 1
.github/workflows/build-and-publish-develop.yml

@@ -51,7 +51,7 @@ jobs:
           release_version: develop-${{ steps.sha.outputs.OC_SHORTHASH }}
       
       - name: Install crane
-        uses: imjasonh/setup-crane@v0.5
+        uses: imjasonh/setup-crane@v0.6
 
       - name: Tag and push latest image
         env:

+ 1 - 1
.github/workflows/build-and-publish-release.yml

@@ -102,7 +102,7 @@ jobs:
             password: ${{ secrets.GITHUB_TOKEN }}
 
       - name: Install crane
-        uses: imjasonh/setup-crane@v0.5
+        uses: imjasonh/setup-crane@v0.6
 
       - name: Copy tags
         env:

+ 5 - 4
Tiltfile.opencost

@@ -78,9 +78,9 @@ def run_opencost(options):
     local_resource(
         name='build-ui',
         dir='../opencost-ui',
-        cmd='npx parcel build src/index.html',
+        cmd='npm run build',
         deps=[
-            '../opencost-ui/src',
+            '../opencost-ui/app',
             '../opencost-ui/package.json',
         ],
         allow_parallel=True,
@@ -93,13 +93,14 @@ def run_opencost(options):
         context='../opencost-ui',
         dockerfile='../opencost-ui/Dockerfile.debug',
         only=[
-            'dist',
+            'build/client',
             'nginx.conf',
             'default.nginx.conf.template',
             'docker-entrypoint.sh',
+            'THIRD_PARTY_LICENSES.txt',
         ],
         live_update=[
-            sync('../opencost-ui/dist', '/var/www'),
+            sync('../opencost-ui/build/client', '/var/www'),
         ],
     )
 

+ 98 - 0
core/pkg/autocomplete/allocation/allocation_test.go

@@ -0,0 +1,98 @@
+package allocation
+
+import (
+	"errors"
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+	"github.com/opencost/opencost/core/pkg/opencost"
+	"github.com/opencost/opencost/core/pkg/util/httputil"
+)
+
+func TestValidateField(t *testing.T) {
+	tests := []struct {
+		in   string
+		want string
+		err  bool
+	}{
+		{"account", "account", false},
+		{"cluster", "cluster", false},
+		{"label", "label", false},
+		{"label:App", "label:App", false},
+		{"namespacelabel:Team", "namespacelabel:Team", false},
+		{"", "", true},
+		{"bad", "", true},
+	}
+	for _, tt := range tests {
+		got, err := ValidateField(tt.in)
+		if tt.err {
+			if err == nil {
+				t.Fatalf("ValidateField(%q) expected error", tt.in)
+			}
+			continue
+		}
+		if err != nil || got != tt.want {
+			t.Fatalf("ValidateField(%q) = %q, %v", tt.in, got, err)
+		}
+	}
+}
+
+func TestNormalizeRequest(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	req := &autocomplete.Request{
+		Field:  "cluster",
+		Search: " x ",
+		Limit:  0,
+		Window: opencost.NewClosedWindow(start, start.Add(24*time.Hour)),
+	}
+	field, err := autocomplete.NormalizeRequest(req, ValidateField, autocomplete.NormalizeOptions{EnsureLabelConfig: true})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if field != "cluster" || req.Search != "x" || req.LabelConfig == nil {
+		t.Fatalf("unexpected normalized request: %+v", req)
+	}
+
+	openReq := &autocomplete.Request{Field: "cluster", Window: opencost.NewWindow(&start, nil)}
+	_, err = autocomplete.NormalizeRequest(openReq, ValidateField, autocomplete.NormalizeOptions{EnsureLabelConfig: true})
+	if err == nil || !errors.Is(err, autocomplete.ErrBadRequest) {
+		t.Fatalf("expected open window error, got %v", err)
+	}
+}
+
+func TestParseRequest(t *testing.T) {
+	windowStr := "2023-01-01T00:00:00Z,2023-01-02T00:00:00Z"
+	qp := httputil.NewQueryParams(map[string][]string{
+		"window": {windowStr},
+		"field":  {"account"},
+		"search": {" ns "},
+	})
+	got, err := ParseRequest(qp, autocomplete.ParseOptions{})
+	if err != nil {
+		t.Fatalf("ParseRequest() error = %v", err)
+	}
+	if got.Field != "account" || got.Search != "ns" {
+		t.Fatalf("unexpected request: %+v", got)
+	}
+}
+
+func TestRouteField(t *testing.T) {
+	tests := []struct {
+		field string
+		route Route
+		key   string
+	}{
+		{"namespacelabel:Team", RouteNamespaceLabelValue, "Team"},
+		{"label", RouteLabelKeys, ""},
+		{"label:App", RouteLabelValue, "App"},
+		{"namespacelabel", RouteNamespaceLabelKeys, ""},
+		{"cluster", RouteDefault, ""},
+	}
+	for _, tt := range tests {
+		route, key, err := RouteField(tt.field)
+		if err != nil || route != tt.route || key != tt.key {
+			t.Fatalf("RouteField(%q) = %v, %q, %v; want %v, %q", tt.field, route, key, err, tt.route, tt.key)
+		}
+	}
+}

+ 13 - 0
core/pkg/autocomplete/allocation/parse.go

@@ -0,0 +1,13 @@
+package allocation
+
+import (
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+	allocationfilter "github.com/opencost/opencost/core/pkg/filter/allocation"
+	"github.com/opencost/opencost/core/pkg/util/httputil"
+)
+
+// ParseRequest builds an autocomplete.Request from query parameters.
+func ParseRequest(qp httputil.QueryParams, opts autocomplete.ParseOptions) (*autocomplete.Request, error) {
+	parser := allocationfilter.NewAllocationFilterParser()
+	return autocomplete.ParseRequest(qp, opts, ValidateField, parser.Parse)
+}

+ 35 - 0
core/pkg/autocomplete/allocation/route.go

@@ -0,0 +1,35 @@
+package allocation
+
+import "github.com/opencost/opencost/core/pkg/autocomplete"
+
+// Route describes how to query a normalized allocation autocomplete field.
+type Route int
+
+const (
+	RouteDefault Route = iota
+	RouteLabelKeys
+	RouteLabelValue
+	RouteNamespaceLabelKeys
+	RouteNamespaceLabelValue
+)
+
+// RouteField maps a normalized field to a query route and label key when applicable.
+func RouteField(field string) (Route, string, error) {
+	if kind, key, err := autocomplete.ParseLabelField(field, autocomplete.LabelPrefix); err == nil {
+		switch kind {
+		case autocomplete.LabelFieldKeys:
+			return RouteLabelKeys, "", nil
+		case autocomplete.LabelFieldValue:
+			return RouteLabelValue, key, nil
+		}
+	}
+	if kind, key, err := autocomplete.ParseLabelField(field, autocomplete.NamespaceLabelPrefix); err == nil {
+		switch kind {
+		case autocomplete.LabelFieldKeys:
+			return RouteNamespaceLabelKeys, "", nil
+		case autocomplete.LabelFieldValue:
+			return RouteNamespaceLabelValue, key, nil
+		}
+	}
+	return RouteDefault, "", nil
+}

+ 11 - 0
core/pkg/autocomplete/allocation/types.go

@@ -0,0 +1,11 @@
+package allocation
+
+import (
+	"context"
+
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+)
+
+type AutocompleteQueryService interface {
+	QueryAllocationAutocomplete(autocomplete.Request, context.Context) (*autocomplete.Response, error)
+}

+ 32 - 0
core/pkg/autocomplete/allocation/validate.go

@@ -0,0 +1,32 @@
+package allocation
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+)
+
+// ValidateField normalizes and validates an allocation autocomplete field name.
+func ValidateField(field string) (string, error) {
+	if field == "" {
+		return "", fmt.Errorf("field is required")
+	}
+
+	f := strings.ToLower(field)
+	switch f {
+	case "account", "cluster", "namespace", "node", "controllerkind", "controllername", "pod", "container", "label", "namespacelabel":
+		return f, nil
+	}
+
+	if strings.HasPrefix(f, "label:") {
+		_, labelKey, _ := strings.Cut(field, ":")
+		return autocomplete.FormatLabelValueField(autocomplete.LabelPrefix, labelKey), nil
+	}
+	if strings.HasPrefix(f, "namespacelabel:") {
+		_, labelKey, _ := strings.Cut(field, ":")
+		return autocomplete.FormatLabelValueField(autocomplete.NamespaceLabelPrefix, labelKey), nil
+	}
+
+	return "", fmt.Errorf("unrecognized field: %s", field)
+}

+ 130 - 0
core/pkg/autocomplete/asset/asset_test.go

@@ -0,0 +1,130 @@
+package asset
+
+import (
+	"errors"
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+	"github.com/opencost/opencost/core/pkg/opencost"
+	"github.com/opencost/opencost/core/pkg/util/httputil"
+)
+
+func TestValidateField(t *testing.T) {
+	tests := []struct {
+		in   string
+		want string
+		err  bool
+	}{
+		{"assettype", "type", false},
+		{"cluster", "cluster", false},
+		{"account", "account", false},
+		{"category", "category", false},
+		{"label", "label", false},
+		{"label:App", "label:App", false},
+		{"bad", "", true},
+	}
+	for _, tt := range tests {
+		got, err := ValidateField(tt.in)
+		if tt.err {
+			if err == nil {
+				t.Fatalf("ValidateField(%q) expected error", tt.in)
+			}
+			continue
+		}
+		if err != nil || got != tt.want {
+			t.Fatalf("ValidateField(%q) = %q, %v", tt.in, got, err)
+		}
+	}
+}
+
+func TestValidateWindow(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	if err := ValidateWindow(opencost.NewClosedWindow(start, start.Add(time.Hour))); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if err := ValidateWindow(opencost.NewWindow(&start, nil)); err == nil {
+		t.Fatal("expected open window error")
+	}
+	end := start.Add(time.Hour)
+	if err := ValidateWindow(opencost.NewWindow(nil, &end)); err == nil {
+		t.Fatal("expected open window error for nil start")
+	}
+	if err := ValidateWindow(opencost.NewWindow(nil, nil)); err == nil {
+		t.Fatal("expected missing start/end error")
+	}
+}
+
+func TestNormalizeRequest(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	req := &autocomplete.Request{
+		TenantID: "t1",
+		Field:    "assettype",
+		Search:   " x ",
+		Limit:    0,
+		Window:   opencost.NewClosedWindow(start, start.Add(24*time.Hour)),
+	}
+	opts := autocomplete.NormalizeOptions{RequireTenantID: true, WindowValidator: ValidateWindow}
+	field, err := autocomplete.NormalizeRequest(req, ValidateField, opts)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if field != "type" || req.Search != "x" {
+		t.Fatalf("unexpected normalized request: %+v", req)
+	}
+
+	_, err = autocomplete.NormalizeRequest(&autocomplete.Request{Field: "cluster", Window: req.Window}, ValidateField, opts)
+	if err == nil || !errors.Is(err, autocomplete.ErrBadRequest) {
+		t.Fatalf("expected tenant ID error, got %v", err)
+	}
+}
+
+func TestParseRequest(t *testing.T) {
+	qp := httputil.NewQueryParams(map[string][]string{"field": {"cluster"}})
+	_, err := ParseRequest(qp, autocomplete.ParseOptions{})
+	if err == nil {
+		t.Fatal("expected missing window error")
+	}
+	got, err := ParseRequest(qp, autocomplete.ParseOptions{DefaultWindow: "30d", DefaultTenantID: "t1"})
+	if err != nil {
+		t.Fatalf("ParseRequest() error = %v", err)
+	}
+	if got.Field != "cluster" || got.TenantID != "t1" {
+		t.Fatalf("unexpected request: %+v", got)
+	}
+}
+
+func TestRouteField(t *testing.T) {
+	tests := []struct {
+		field string
+		route Route
+		key   string
+	}{
+		{"type", RouteStaticType, ""},
+		{"category", RouteStaticCategory, ""},
+		{"label", RouteLabelKeys, ""},
+		{"label:App", RouteLabelValue, "App"},
+		{"cluster", RouteDefault, ""},
+	}
+	for _, tt := range tests {
+		route, key, err := RouteField(tt.field)
+		if err != nil || route != tt.route || key != tt.key {
+			t.Fatalf("RouteField(%q) = %v, %q, %v; want %v, %q", tt.field, route, key, err, tt.route, tt.key)
+		}
+	}
+}
+
+func TestStaticValues(t *testing.T) {
+	types := StaticTypes()
+	if len(types) == 0 {
+		t.Fatal("expected static types")
+	}
+	categories := StaticCategories()
+	if len(categories) == 0 {
+		t.Fatal("expected static categories")
+	}
+	got := FilterStaticValues(StaticTypes(), "node")
+	if len(got) != 1 || got[0] != "node" {
+		t.Fatalf("FilterStaticValues() = %v", got)
+	}
+}

+ 16 - 0
core/pkg/autocomplete/asset/parse.go

@@ -0,0 +1,16 @@
+package asset
+
+import (
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+	assetfilter "github.com/opencost/opencost/core/pkg/filter/asset"
+	"github.com/opencost/opencost/core/pkg/util/httputil"
+)
+
+// ParseRequest builds an autocomplete.Request from query parameters.
+func ParseRequest(qp httputil.QueryParams, opts autocomplete.ParseOptions) (*autocomplete.Request, error) {
+	if opts.WindowValidator == nil {
+		opts.WindowValidator = ValidateWindow
+	}
+	parser := assetfilter.NewAssetFilterParser()
+	return autocomplete.ParseRequest(qp, opts, ValidateField, parser.Parse)
+}

+ 33 - 0
core/pkg/autocomplete/asset/route.go

@@ -0,0 +1,33 @@
+package asset
+
+import "github.com/opencost/opencost/core/pkg/autocomplete"
+
+// Route describes how to query a normalized asset autocomplete field.
+type Route int
+
+const (
+	RouteDefault Route = iota
+	RouteLabelKeys
+	RouteLabelValue
+	RouteStaticType
+	RouteStaticCategory
+)
+
+// RouteField maps a normalized field to a query route and label key when applicable.
+func RouteField(field string) (Route, string, error) {
+	switch field {
+	case "type":
+		return RouteStaticType, "", nil
+	case "category":
+		return RouteStaticCategory, "", nil
+	}
+	if kind, key, err := autocomplete.ParseLabelField(field, autocomplete.LabelPrefix); err == nil {
+		switch kind {
+		case autocomplete.LabelFieldKeys:
+			return RouteLabelKeys, "", nil
+		case autocomplete.LabelFieldValue:
+			return RouteLabelValue, key, nil
+		}
+	}
+	return RouteDefault, "", nil
+}

+ 34 - 0
core/pkg/autocomplete/asset/static.go

@@ -0,0 +1,34 @@
+package asset
+
+import (
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+// StaticTypes returns canonical asset type strings for autocomplete.
+func StaticTypes() []string {
+	return []string{
+		"cloud",
+		"clustermanagement",
+		"disk",
+		"loadbalancer",
+		"network",
+		"node",
+		"shared",
+	}
+}
+
+// StaticCategories returns canonical asset category strings for autocomplete.
+func StaticCategories() []string {
+	return []string{
+		opencost.ComputeCategory,
+		opencost.StorageCategory,
+		opencost.NetworkCategory,
+		opencost.ManagementCategory,
+	}
+}
+
+// FilterStaticValues filters static enumeration values by search text.
+func FilterStaticValues(values []string, search string) []string {
+	return autocomplete.FilterBySearch(values, search)
+}

+ 11 - 0
core/pkg/autocomplete/asset/types.go

@@ -0,0 +1,11 @@
+package asset
+
+import (
+	"context"
+
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+)
+
+type AutocompleteQueryService interface {
+	QueryAssetAutocomplete(autocomplete.Request, context.Context) (*autocomplete.Response, error)
+}

+ 39 - 0
core/pkg/autocomplete/asset/validate.go

@@ -0,0 +1,39 @@
+package asset
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+// ValidateField normalizes and validates an asset autocomplete field name.
+func ValidateField(field string) (string, error) {
+	f := strings.ToLower(field)
+	switch f {
+	case "account", "cluster", "name", "provider", "providerid", "type", "assettype", "category":
+		if f == "assettype" {
+			return "type", nil
+		}
+		return f, nil
+	}
+	if f == "label" {
+		return f, nil
+	}
+	if strings.HasPrefix(f, "label:") {
+		_, labelKey, _ := strings.Cut(field, ":")
+		return autocomplete.FormatLabelValueField(autocomplete.LabelPrefix, labelKey), nil
+	}
+	return "", fmt.Errorf("unrecognized field: %s", field)
+}
+
+func ValidateWindow(window opencost.Window) error {
+	if window.IsOpen() {
+		return fmt.Errorf("%w: invalid window: %s", autocomplete.ErrBadRequest, window.String())
+	}
+	if window.Start() == nil || window.End() == nil {
+		return fmt.Errorf("%w: invalid window: missing start or end", autocomplete.ErrBadRequest)
+	}
+	return nil
+}

+ 79 - 0
core/pkg/autocomplete/autocomplete_test.go

@@ -0,0 +1,79 @@
+package autocomplete
+
+import (
+	"errors"
+	"testing"
+)
+
+func TestSanitizeSearch(t *testing.T) {
+	if got := SanitizeSearch("  ec2  "); got != "ec2" {
+		t.Fatalf("SanitizeSearch() = %q, want %q", got, "ec2")
+	}
+}
+
+func TestNormalizeLimit(t *testing.T) {
+	got, err := NormalizeLimit(0)
+	if err != nil || got != DefaultResultLimit {
+		t.Fatalf("NormalizeLimit(0) = %d, %v", got, err)
+	}
+	got, err = NormalizeLimit(50)
+	if err != nil || got != 50 {
+		t.Fatalf("NormalizeLimit(50) = %d, %v", got, err)
+	}
+	_, err = NormalizeLimit(MaxResultLimit + 1)
+	if err == nil || !errors.Is(err, ErrBadRequest) {
+		t.Fatalf("expected ErrBadRequest, got %v", err)
+	}
+}
+
+func TestFilterBySearch(t *testing.T) {
+	got := FilterBySearch([]string{"AmazonEC2"}, "ec2")
+	if len(got) != 1 || got[0] != "AmazonEC2" {
+		t.Fatalf("FilterBySearch() = %v", got)
+	}
+	got = FilterBySearch([]string{"AmazonEC2", "S3"}, "")
+	if len(got) != 2 {
+		t.Fatalf("FilterBySearch empty search = %v", got)
+	}
+}
+
+func TestMapValueFold(t *testing.T) {
+	values := map[string]string{"Team": "platform"}
+	if got, ok := MapValueFold(values, "Team"); !ok || got != "platform" {
+		t.Fatalf("MapValueFold exact match = %q, %v", got, ok)
+	}
+	if got, ok := MapValueFold(values, "team"); !ok || got != "platform" {
+		t.Fatalf("MapValueFold() = %q, %v", got, ok)
+	}
+	if _, ok := MapValueFold(values, "missing"); ok {
+		t.Fatal("expected missing key")
+	}
+}
+
+func TestUniqueSortedLimited(t *testing.T) {
+	set := map[string]struct{}{"b": {}, "a": {}, "c": {}}
+	got := UniqueSortedLimited(set, 2)
+	if len(got) != 2 || got[0] != "a" || got[1] != "b" {
+		t.Fatalf("UniqueSortedLimited() = %v", got)
+	}
+	all := UniqueSortedLimited(map[string]struct{}{"z": {}, "y": {}}, 5)
+	if len(all) != 2 || all[0] != "y" || all[1] != "z" {
+		t.Fatalf("UniqueSortedLimited no truncate = %v", all)
+	}
+}
+
+func TestToSet(t *testing.T) {
+	set := ToSet([]string{"a", "b", "a"})
+	if len(set) != 2 {
+		t.Fatalf("ToSet() = %v", set)
+	}
+}
+
+func TestIsBadRequest(t *testing.T) {
+	if !IsBadRequest(ErrBadRequest) {
+		t.Fatal("expected ErrBadRequest")
+	}
+	if IsBadRequest(errors.New("other")) {
+		t.Fatal("expected false for unrelated error")
+	}
+}

+ 82 - 0
core/pkg/autocomplete/cloudcost/cloudcost_test.go

@@ -0,0 +1,82 @@
+package cloudcost
+
+import (
+	"errors"
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+	"github.com/opencost/opencost/core/pkg/opencost"
+	"github.com/opencost/opencost/core/pkg/util/httputil"
+)
+
+func TestValidateField(t *testing.T) {
+	got, err := ValidateField("accountID")
+	if err != nil || got != "accountID" {
+		t.Fatalf("ValidateField(accountID) = %q, %v", got, err)
+	}
+
+	got, err = ValidateField("label:App")
+	if err != nil || got != "label:App" {
+		t.Fatalf("ValidateField(label:App) = %q, %v", got, err)
+	}
+
+	got, err = ValidateField("label")
+	if err != nil || got != "label" {
+		t.Fatalf("ValidateField(label) = %q, %v", got, err)
+	}
+
+	_, err = ValidateField("")
+	if err == nil {
+		t.Fatal("expected error for empty field")
+	}
+
+	_, err = ValidateField("bad")
+	if err == nil {
+		t.Fatal("expected error for unrecognized field")
+	}
+}
+
+func TestNormalizeRequest(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	req := &autocomplete.Request{
+		Field:  "accountID",
+		Search: " x ",
+		Limit:  0,
+		Window: opencost.NewClosedWindow(start, start.Add(24*time.Hour)),
+	}
+	field, err := autocomplete.NormalizeRequest(req, ValidateField, autocomplete.NormalizeOptions{})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if field != "accountID" || req.Search != "x" {
+		t.Fatalf("unexpected normalized request: %+v", req)
+	}
+
+	openReq := &autocomplete.Request{Field: "accountID", Window: opencost.NewWindow(&start, nil)}
+	_, err = autocomplete.NormalizeRequest(openReq, ValidateField, autocomplete.NormalizeOptions{})
+	if err == nil || !errors.Is(err, autocomplete.ErrBadRequest) {
+		t.Fatalf("expected open window error, got %v", err)
+	}
+}
+
+func TestParseRequest(t *testing.T) {
+	windowStr := "2023-01-01T00:00:00Z,2023-01-02T00:00:00Z"
+	qp := httputil.NewQueryParams(map[string][]string{
+		"window": {windowStr},
+		"field":  {"accountID"},
+		"search": {" aws "},
+	})
+	got, err := ParseRequest(qp, autocomplete.ParseOptions{})
+	if err != nil {
+		t.Fatalf("ParseRequest() error = %v", err)
+	}
+	if got.Field != "accountID" || got.Search != "aws" {
+		t.Fatalf("unexpected request: %+v", got)
+	}
+
+	_, err = ParseRequest(httputil.NewQueryParams(map[string][]string{"field": {"accountID"}}), autocomplete.ParseOptions{})
+	if err == nil || !errors.Is(err, autocomplete.ErrBadRequest) {
+		t.Fatalf("expected missing window error, got %v", err)
+	}
+}

+ 13 - 0
core/pkg/autocomplete/cloudcost/parse.go

@@ -0,0 +1,13 @@
+package cloudcost
+
+import (
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+	cloudcostfilter "github.com/opencost/opencost/core/pkg/filter/cloudcost"
+	"github.com/opencost/opencost/core/pkg/util/httputil"
+)
+
+// ParseRequest builds an autocomplete.Request from query parameters.
+func ParseRequest(qp httputil.QueryParams, opts autocomplete.ParseOptions) (*autocomplete.Request, error) {
+	parser := cloudcostfilter.NewCloudCostFilterParser()
+	return autocomplete.ParseRequest(qp, opts, ValidateField, parser.Parse)
+}

+ 11 - 0
core/pkg/autocomplete/cloudcost/types.go

@@ -0,0 +1,11 @@
+package cloudcost
+
+import (
+	"context"
+
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+)
+
+type AutocompleteQueryService interface {
+	QueryCloudCostAutocomplete(context.Context, autocomplete.Request) (*autocomplete.Response, error)
+}

+ 30 - 0
core/pkg/autocomplete/cloudcost/validate.go

@@ -0,0 +1,30 @@
+package cloudcost
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+// ValidateField normalizes and validates a cloud cost autocomplete field name.
+func ValidateField(field string) (string, error) {
+	if field == "" {
+		return "", fmt.Errorf("field is required")
+	}
+
+	f := strings.ToLower(field)
+	if f == "label" {
+		return f, nil
+	}
+	if strings.HasPrefix(f, "label:") {
+		_, labelKey, _ := strings.Cut(field, ":")
+		return "label:" + labelKey, nil
+	}
+
+	property, err := opencost.ParseCloudCostProperty(field)
+	if err != nil {
+		return "", err
+	}
+	return string(property), nil
+}

+ 11 - 0
core/pkg/autocomplete/errors.go

@@ -0,0 +1,11 @@
+package autocomplete
+
+import "errors"
+
+// ErrBadRequest indicates a client validation error for autocomplete requests.
+var ErrBadRequest = errors.New("autocomplete bad request")
+
+// IsBadRequest reports whether err is a client validation error.
+func IsBadRequest(err error) bool {
+	return errors.Is(err, ErrBadRequest)
+}

+ 49 - 0
core/pkg/autocomplete/label.go

@@ -0,0 +1,49 @@
+package autocomplete
+
+import (
+	"fmt"
+	"strings"
+)
+
+// LabelFieldPrefix identifies a label key namespace for autocomplete fields.
+type LabelFieldPrefix string
+
+const (
+	LabelPrefix          LabelFieldPrefix = "label"
+	NamespaceLabelPrefix LabelFieldPrefix = "namespacelabel"
+)
+
+// LabelFieldKind describes how an autocomplete field relates to labels.
+type LabelFieldKind int
+
+const (
+	LabelFieldNone LabelFieldKind = iota
+	LabelFieldKeys
+	LabelFieldValue
+)
+
+// ParseLabelField classifies field relative to prefix (e.g. label, label:app).
+func ParseLabelField(field string, prefix LabelFieldPrefix) (LabelFieldKind, string, error) {
+	if field == "" {
+		return LabelFieldNone, "", fmt.Errorf("field is required")
+	}
+
+	p := string(prefix)
+	f := strings.ToLower(field)
+	if f == p {
+		return LabelFieldKeys, "", nil
+	}
+
+	valuePrefix := p + ":"
+	if strings.HasPrefix(f, valuePrefix) {
+		_, labelKey, _ := strings.Cut(field, ":")
+		return LabelFieldValue, labelKey, nil
+	}
+
+	return LabelFieldNone, "", nil
+}
+
+// FormatLabelValueField returns a normalized label:value field preserving key casing.
+func FormatLabelValueField(prefix LabelFieldPrefix, labelKey string) string {
+	return string(prefix) + ":" + labelKey
+}

+ 37 - 0
core/pkg/autocomplete/label_test.go

@@ -0,0 +1,37 @@
+package autocomplete
+
+import "testing"
+
+func TestParseLabelField(t *testing.T) {
+	kind, key, err := ParseLabelField("label:App", LabelPrefix)
+	if err != nil || kind != LabelFieldValue || key != "App" {
+		t.Fatalf("ParseLabelField(label:App) = %v, %q, %v", kind, key, err)
+	}
+
+	kind, key, err = ParseLabelField("label", LabelPrefix)
+	if err != nil || kind != LabelFieldKeys || key != "" {
+		t.Fatalf("ParseLabelField(label) = %v, %q, %v", kind, key, err)
+	}
+
+	kind, _, err = ParseLabelField("cluster", LabelPrefix)
+	if err != nil || kind != LabelFieldNone {
+		t.Fatalf("ParseLabelField(cluster) = %v, %v", kind, err)
+	}
+
+	_, _, err = ParseLabelField("", LabelPrefix)
+	if err == nil {
+		t.Fatal("expected error for empty field")
+	}
+
+	kind, key, err = ParseLabelField("namespacelabel:Team", NamespaceLabelPrefix)
+	if err != nil || kind != LabelFieldValue || key != "Team" {
+		t.Fatalf("ParseLabelField(namespacelabel:Team) = %v, %q, %v", kind, key, err)
+	}
+}
+
+func TestFormatLabelValueField(t *testing.T) {
+	got := FormatLabelValueField(LabelPrefix, "App")
+	if got != "label:App" {
+		t.Fatalf("FormatLabelValueField() = %q", got)
+	}
+}

+ 4 - 0
core/pkg/autocomplete/limits.go

@@ -0,0 +1,4 @@
+package autocomplete
+
+const DefaultResultLimit = 100
+const MaxResultLimit = 1000

+ 64 - 0
core/pkg/autocomplete/normalize.go

@@ -0,0 +1,64 @@
+package autocomplete
+
+import (
+	"fmt"
+
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+// FieldValidator normalizes and validates an autocomplete field name.
+type FieldValidator func(field string) (string, error)
+
+// WindowValidator validates a parsed autocomplete window.
+type WindowValidator func(window opencost.Window) error
+
+// DefaultWindowValidator rejects open-ended windows.
+func DefaultWindowValidator(window opencost.Window) error {
+	if window.IsOpen() {
+		return fmt.Errorf("%w: invalid window: %s", ErrBadRequest, window.String())
+	}
+	return nil
+}
+
+// NormalizeOptions configures shared request normalization.
+type NormalizeOptions struct {
+	RequireTenantID   bool
+	EnsureLabelConfig bool
+	WindowValidator   WindowValidator
+}
+
+// NormalizeRequest validates and normalizes an autocomplete request in place.
+func NormalizeRequest(req *Request, validateField FieldValidator, opts NormalizeOptions) (string, error) {
+	if req == nil {
+		return "", fmt.Errorf("%w: request is nil", ErrBadRequest)
+	}
+	if opts.RequireTenantID && req.TenantID == "" {
+		return "", fmt.Errorf("%w: tenant ID is required", ErrBadRequest)
+	}
+
+	windowValidator := opts.WindowValidator
+	if windowValidator == nil {
+		windowValidator = DefaultWindowValidator
+	}
+	if err := windowValidator(req.Window); err != nil {
+		return "", err
+	}
+
+	field, err := validateField(req.Field)
+	if err != nil {
+		return "", fmt.Errorf("%w: invalid field: %w", ErrBadRequest, err)
+	}
+
+	limit, err := NormalizeLimit(req.Limit)
+	if err != nil {
+		return "", err
+	}
+
+	req.Field = field
+	req.Search = SanitizeSearch(req.Search)
+	req.Limit = limit
+	if opts.EnsureLabelConfig && req.LabelConfig == nil {
+		req.LabelConfig = opencost.NewLabelConfig()
+	}
+	return field, nil
+}

+ 86 - 0
core/pkg/autocomplete/parse.go

@@ -0,0 +1,86 @@
+package autocomplete
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/filter"
+	"github.com/opencost/opencost/core/pkg/opencost"
+	"github.com/opencost/opencost/core/pkg/util/httputil"
+)
+
+// FilterParser parses a filter query string for autocomplete requests.
+type FilterParser func(filterString string) (filter.Filter, error)
+
+// ParseOptions configures ParseRequest.
+type ParseOptions struct {
+	DefaultWindow   string
+	DefaultTenantID string
+	LabelConfig     *opencost.LabelConfig
+	UTCOffset       *time.Duration
+	WindowValidator WindowValidator
+}
+
+// ParseRequest builds a Request from query parameters.
+func ParseRequest(qp httputil.QueryParams, opts ParseOptions, validateField FieldValidator, parseFilter FilterParser) (*Request, error) {
+	windowStr := qp.Get("window", opts.DefaultWindow)
+	if windowStr == "" {
+		return nil, fmt.Errorf("%w: missing required 'window' parameter", ErrBadRequest)
+	}
+
+	var window opencost.Window
+	var err error
+	if opts.UTCOffset != nil {
+		window, err = opencost.ParseWindowWithOffset(windowStr, *opts.UTCOffset)
+	} else {
+		window, err = opencost.ParseWindowUTC(windowStr)
+	}
+	if err != nil {
+		return nil, fmt.Errorf("%w: invalid window parameter: %w", ErrBadRequest, err)
+	}
+
+	windowValidator := opts.WindowValidator
+	if windowValidator == nil {
+		windowValidator = DefaultWindowValidator
+	}
+	if err := windowValidator(window); err != nil {
+		return nil, err
+	}
+
+	field, err := validateField(qp.Get("field", ""))
+	if err != nil {
+		return nil, fmt.Errorf("%w: invalid field: %w", ErrBadRequest, err)
+	}
+
+	filterString := qp.Get("filter", "")
+	var parsedFilter filter.Filter
+	if filterString != "" {
+		if parseFilter == nil {
+			return nil, fmt.Errorf("%w: invalid 'filter' parameter: filter parser is required", ErrBadRequest)
+		}
+		parsedFilter, err = parseFilter(filterString)
+		if err != nil {
+			return nil, fmt.Errorf("%w: invalid 'filter' parameter: %w", ErrBadRequest, err)
+		}
+	}
+
+	tenantID := qp.Get("tenantId", opts.DefaultTenantID)
+	if tenantID == "" {
+		tenantID = opts.DefaultTenantID
+	}
+
+	labelConfig := opts.LabelConfig
+	if labelConfig == nil {
+		labelConfig = opencost.NewLabelConfig()
+	}
+
+	return &Request{
+		TenantID:    tenantID,
+		Search:      SanitizeSearch(qp.Get("search", "")),
+		Field:       field,
+		Limit:       qp.GetInt("limit", 0),
+		Window:      window,
+		Filter:      parsedFilter,
+		LabelConfig: labelConfig,
+	}, nil
+}

+ 22 - 0
core/pkg/autocomplete/request.go

@@ -0,0 +1,22 @@
+package autocomplete
+
+import (
+	"github.com/opencost/opencost/core/pkg/filter"
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+// Request is the shared autocomplete request shape used by allocation, asset, and cloud cost APIs.
+type Request struct {
+	TenantID    string
+	Search      string
+	Field       string
+	Limit       int
+	Window      opencost.Window
+	Filter      filter.Filter
+	LabelConfig *opencost.LabelConfig
+}
+
+// Response is the shared autocomplete response shape.
+type Response struct {
+	Data []string `json:"data"`
+}

+ 158 - 0
core/pkg/autocomplete/request_test.go

@@ -0,0 +1,158 @@
+package autocomplete
+
+import (
+	"errors"
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/filter"
+	"github.com/opencost/opencost/core/pkg/opencost"
+	"github.com/opencost/opencost/core/pkg/util/httputil"
+)
+
+func validateTestField(field string) (string, error) {
+	if field == "" {
+		return "", ErrBadRequest
+	}
+	return field, nil
+}
+
+func TestNormalizeRequest(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	req := &Request{
+		TenantID: "t1",
+		Field:    "cluster",
+		Search:   " x ",
+		Limit:    0,
+		Window:   opencost.NewClosedWindow(start, start.Add(24*time.Hour)),
+	}
+	field, err := NormalizeRequest(req, validateTestField, NormalizeOptions{RequireTenantID: true})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if field != "cluster" || req.Search != "x" || req.Limit != DefaultResultLimit {
+		t.Fatalf("unexpected normalized request: %+v", req)
+	}
+
+	_, err = NormalizeRequest(req, validateTestField, NormalizeOptions{RequireTenantID: true, EnsureLabelConfig: true})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if req.LabelConfig == nil {
+		t.Fatal("expected default label config")
+	}
+
+	_, err = NormalizeRequest(nil, validateTestField, NormalizeOptions{})
+	if err == nil || !errors.Is(err, ErrBadRequest) {
+		t.Fatalf("expected nil request error, got %v", err)
+	}
+
+	openReq := &Request{Field: "cluster", Window: opencost.NewWindow(&start, nil)}
+	_, err = NormalizeRequest(openReq, validateTestField, NormalizeOptions{})
+	if err == nil || !errors.Is(err, ErrBadRequest) {
+		t.Fatalf("expected open window error, got %v", err)
+	}
+
+	_, err = NormalizeRequest(&Request{Field: "cluster", Window: req.Window}, validateTestField, NormalizeOptions{RequireTenantID: true})
+	if err == nil || !errors.Is(err, ErrBadRequest) {
+		t.Fatalf("expected tenant ID error, got %v", err)
+	}
+
+	limitReq := &Request{
+		TenantID: "t1",
+		Field:    "cluster",
+		Window:   opencost.NewClosedWindow(start, start.Add(24*time.Hour)),
+		Limit:    MaxResultLimit + 1,
+	}
+	_, err = NormalizeRequest(limitReq, validateTestField, NormalizeOptions{RequireTenantID: true})
+	if err == nil || !errors.Is(err, ErrBadRequest) {
+		t.Fatalf("expected limit error, got %v", err)
+	}
+}
+
+func TestDefaultWindowValidator(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+	if err := DefaultWindowValidator(opencost.NewClosedWindow(start, end)); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if err := DefaultWindowValidator(opencost.NewWindow(&start, nil)); err == nil {
+		t.Fatal("expected open window error")
+	}
+}
+
+func TestParseRequest(t *testing.T) {
+	windowStr := "2023-01-01T00:00:00Z,2023-01-02T00:00:00Z"
+	qp := httputil.NewQueryParams(map[string][]string{
+		"window":   {windowStr},
+		"field":    {"cluster"},
+		"search":   {" ns "},
+		"tenantId": {"t1"},
+	})
+	got, err := ParseRequest(qp, ParseOptions{}, validateTestField, nil)
+	if err != nil {
+		t.Fatalf("ParseRequest() error = %v", err)
+	}
+	if got.Field != "cluster" || got.Search != "ns" || got.TenantID != "t1" {
+		t.Fatalf("unexpected request: %+v", got)
+	}
+
+	_, err = ParseRequest(httputil.NewQueryParams(map[string][]string{"field": {"cluster"}}), ParseOptions{}, validateTestField, nil)
+	if err == nil || !errors.Is(err, ErrBadRequest) {
+		t.Fatalf("expected missing window error, got %v", err)
+	}
+
+	_, err = ParseRequest(httputil.NewQueryParams(map[string][]string{
+		"window": {"bad"},
+		"field":  {"cluster"},
+	}), ParseOptions{}, validateTestField, nil)
+	if err == nil || !errors.Is(err, ErrBadRequest) {
+		t.Fatalf("expected invalid window error, got %v", err)
+	}
+
+	offset := 5 * time.Hour
+	_, err = ParseRequest(qp, ParseOptions{UTCOffset: &offset}, validateTestField, nil)
+	if err != nil {
+		t.Fatalf("ParseRequest with offset error = %v", err)
+	}
+
+	noFilterQP := httputil.NewQueryParams(map[string][]string{
+		"window": {windowStr},
+		"field":  {"cluster"},
+		"filter": {"cluster:\"prod\""},
+	})
+	_, err = ParseRequest(noFilterQP, ParseOptions{}, validateTestField, nil)
+	if err == nil || !errors.Is(err, ErrBadRequest) {
+		t.Fatalf("expected filter parser required error, got %v", err)
+	}
+
+	parseFilter := func(filterString string) (filter.Filter, error) {
+		if filterString == "bad" {
+			return nil, errors.New("bad filter")
+		}
+		return nil, nil
+	}
+
+	badFilterQP := httputil.NewQueryParams(map[string][]string{
+		"window": {windowStr},
+		"field":  {"cluster"},
+		"filter": {"bad"},
+	})
+	_, err = ParseRequest(badFilterQP, ParseOptions{}, validateTestField, parseFilter)
+	if err == nil || !errors.Is(err, ErrBadRequest) {
+		t.Fatalf("expected filter parse error, got %v", err)
+	}
+
+	okFilterQP := httputil.NewQueryParams(map[string][]string{
+		"window": {windowStr},
+		"field":  {"cluster"},
+		"filter": {"ok"},
+	})
+	got, err = ParseRequest(okFilterQP, ParseOptions{}, validateTestField, parseFilter)
+	if err != nil {
+		t.Fatalf("ParseRequest with filter error = %v", err)
+	}
+	if got.Field != "cluster" {
+		t.Fatalf("unexpected request: %+v", got)
+	}
+}

+ 76 - 0
core/pkg/autocomplete/util.go

@@ -0,0 +1,76 @@
+package autocomplete
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+)
+
+// SanitizeSearch trims whitespace from an autocomplete search string.
+func SanitizeSearch(search string) string {
+	return strings.TrimSpace(search)
+}
+
+// NormalizeLimit applies default and maximum limits for autocomplete results.
+func NormalizeLimit(limit int) (int, error) {
+	if limit <= 0 {
+		return DefaultResultLimit, nil
+	}
+	if limit > MaxResultLimit {
+		return 0, fmt.Errorf("%w: exceeded maximum autocomplete result limit of %d", ErrBadRequest, MaxResultLimit)
+	}
+	return limit, nil
+}
+
+// MapValueFold returns the value for key using case-insensitive key matching.
+func MapValueFold(values map[string]string, key string) (string, bool) {
+	if v, ok := values[key]; ok {
+		return v, true
+	}
+	for k, v := range values {
+		if strings.EqualFold(k, key) {
+			return v, true
+		}
+	}
+	return "", false
+}
+
+// UniqueSortedLimited returns sorted unique strings capped at limit.
+func UniqueSortedLimited(values map[string]struct{}, limit int) []string {
+	out := make([]string, 0, len(values))
+	for v := range values {
+		out = append(out, v)
+	}
+	sort.Strings(out)
+	if len(out) > limit {
+		return out[:limit]
+	}
+	return out
+}
+
+// FilterBySearch returns values from list that contain search (case-insensitive).
+func FilterBySearch(list []string, search string) []string {
+	search = SanitizeSearch(search)
+	if search == "" {
+		out := make([]string, len(list))
+		copy(out, list)
+		return out
+	}
+	needle := strings.ToLower(search)
+	out := make([]string, 0, len(list))
+	for _, value := range list {
+		if strings.Contains(strings.ToLower(value), needle) {
+			out = append(out, value)
+		}
+	}
+	return out
+}
+
+// ToSet converts a string slice to a set map.
+func ToSet(values []string) map[string]struct{} {
+	out := make(map[string]struct{}, len(values))
+	for _, v := range values {
+		out[v] = struct{}{}
+	}
+	return out
+}

+ 41 - 0
pkg/allocation/autocomplete_parser_test.go

@@ -0,0 +1,41 @@
+package allocation
+
+import (
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+	coreallocation "github.com/opencost/opencost/core/pkg/autocomplete/allocation"
+	"github.com/opencost/opencost/core/pkg/util/httputil"
+)
+
+func TestParseAllocationAutocompleteRequest(t *testing.T) {
+	windowStr := "2023-01-01T00:00:00Z,2023-01-02T00:00:00Z"
+	qp := httputil.NewQueryParams(map[string][]string{
+		"window": {windowStr},
+		"field":  {"account"},
+		"search": {" ns "},
+	})
+	got, err := coreallocation.ParseRequest(qp, autocomplete.ParseOptions{
+		DefaultWindow: "30d",
+	})
+	if err != nil {
+		t.Fatalf("ParseRequest() error = %v", err)
+	}
+	if got.Field != "account" || got.Search != "ns" {
+		t.Fatalf("unexpected request: %+v", got)
+	}
+}
+
+func TestValidateAutocompleteField_account(t *testing.T) {
+	got, err := coreallocation.ValidateField("account")
+	if err != nil || got != "account" {
+		t.Fatalf("ValidateField(account) = %q, %v", got, err)
+	}
+}
+
+func TestRouteAllocationAutocompleteField(t *testing.T) {
+	route, key, err := coreallocation.RouteField("namespacelabel:Team")
+	if err != nil || route != coreallocation.RouteNamespaceLabelValue || key != "Team" {
+		t.Fatalf("RouteField() = %v, %q, %v", route, key, err)
+	}
+}

+ 13 - 94
pkg/allocation/autocompletequeryservice.go

@@ -1,56 +1,20 @@
 package allocation
 
 import (
-	"context"
-	"errors"
 	"fmt"
-	"sort"
 	"strings"
 
-	"github.com/opencost/opencost/core/pkg/filter"
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+	coreallocation "github.com/opencost/opencost/core/pkg/autocomplete/allocation"
 	"github.com/opencost/opencost/core/pkg/opencost"
 )
 
-// ErrAutocompleteBadRequest indicates a client error in an autocomplete request.
-var ErrAutocompleteBadRequest = errors.New("autocomplete bad request")
-
-// IsAutocompleteBadRequest reports whether err is a client validation error.
-func IsAutocompleteBadRequest(err error) bool {
-	return errors.Is(err, ErrAutocompleteBadRequest)
-}
-
-const DefaultAutocompleteResultLimit = 100
-const MaxAutocompleteResultLimit = 1000
-
-type AllocationAutocompleteRequest struct {
-	Search      string
-	Field       string
-	Limit       int
-	Window      opencost.Window
-	Filter      filter.Filter
-	LabelConfig *opencost.LabelConfig
-}
-
-type AllocationAutocompleteResponse struct {
-	Data []string `json:"data"`
-}
-
-type AutocompleteQueryService interface {
-	QueryAllocationAutocomplete(AllocationAutocompleteRequest, context.Context) (*AllocationAutocompleteResponse, error)
-}
-
-func QueryAllocationAutocompleteFromSetRange(asr *opencost.AllocationSetRange, req AllocationAutocompleteRequest) (*AllocationAutocompleteResponse, error) {
-	field, err := validateAutocompleteField(req.Field)
+func QueryAllocationAutocompleteFromSetRange(asr *opencost.AllocationSetRange, req autocomplete.Request) (*autocomplete.Response, error) {
+	field, err := autocomplete.NormalizeRequest(&req, coreallocation.ValidateField, autocomplete.NormalizeOptions{
+		EnsureLabelConfig: true,
+	})
 	if err != nil {
-		return nil, fmt.Errorf("%w: invalid field: %w", ErrAutocompleteBadRequest, err)
-	}
-
-	limit := req.Limit
-	if limit <= 0 {
-		limit = DefaultAutocompleteResultLimit
-	}
-	if limit > MaxAutocompleteResultLimit {
-		return nil, fmt.Errorf("%w: exceeded maximum autocomplete result limit of %d", ErrAutocompleteBadRequest, MaxAutocompleteResultLimit)
+		return nil, err
 	}
 
 	var matcher opencost.AllocationMatcher
@@ -58,7 +22,7 @@ func QueryAllocationAutocompleteFromSetRange(asr *opencost.AllocationSetRange, r
 		compiler := opencost.NewAllocationMatchCompiler(req.LabelConfig)
 		matcher, err = compiler.Compile(req.Filter)
 		if err != nil {
-			return nil, fmt.Errorf("%w: failed to compile filter: %w", ErrAutocompleteBadRequest, err)
+			return nil, fmt.Errorf("%w: failed to compile filter: %w", autocomplete.ErrBadRequest, err)
 		}
 	}
 
@@ -89,34 +53,13 @@ func QueryAllocationAutocompleteFromSetRange(asr *opencost.AllocationSetRange, r
 		}
 	}
 
-	return &AllocationAutocompleteResponse{Data: uniqueSortedLimited(results, limit)}, nil
-}
-
-func validateAutocompleteField(field string) (string, error) {
-	if field == "" {
-		return "", fmt.Errorf("field is required")
-	}
-
-	f := strings.ToLower(field)
-	switch f {
-	case "cluster", "namespace", "node", "controllerkind", "controllername", "pod", "container", "label", "namespacelabel":
-		return f, nil
-	}
-
-	if strings.HasPrefix(f, "label:") {
-		_, labelKey, _ := strings.Cut(f, ":")
-		return "label:" + labelKey, nil
-	}
-	if strings.HasPrefix(f, "namespacelabel:") {
-		_, labelKey, _ := strings.Cut(f, ":")
-		return "namespacelabel:" + labelKey, nil
-	}
-
-	return "", fmt.Errorf("unrecognized field: %s", field)
+	return &autocomplete.Response{Data: autocomplete.UniqueSortedLimited(results, req.Limit)}, nil
 }
 
 func allocationAutocompleteValues(props *opencost.AllocationProperties, field string) []string {
 	switch {
+	case field == "account":
+		return nil
 	case field == "cluster":
 		return []string{props.Cluster}
 	case field == "namespace":
@@ -135,14 +78,14 @@ func allocationAutocompleteValues(props *opencost.AllocationProperties, field st
 		return mapKeys(props.Labels)
 	case strings.HasPrefix(field, "label:"):
 		label := strings.TrimPrefix(field, "label:")
-		if v, ok := mapValueFold(props.Labels, label); ok {
+		if v, ok := autocomplete.MapValueFold(props.Labels, label); ok {
 			return []string{v}
 		}
 	case field == "namespacelabel":
 		return mapKeys(props.NamespaceLabels)
 	case strings.HasPrefix(field, "namespacelabel:"):
 		label := strings.TrimPrefix(field, "namespacelabel:")
-		if v, ok := mapValueFold(props.NamespaceLabels, label); ok {
+		if v, ok := autocomplete.MapValueFold(props.NamespaceLabels, label); ok {
 			return []string{v}
 		}
 	}
@@ -156,27 +99,3 @@ func mapKeys(values map[string]string) []string {
 	}
 	return result
 }
-
-func mapValueFold(values map[string]string, key string) (string, bool) {
-	if v, ok := values[key]; ok {
-		return v, true
-	}
-	for k, v := range values {
-		if strings.EqualFold(k, key) {
-			return v, true
-		}
-	}
-	return "", false
-}
-
-func uniqueSortedLimited(values map[string]struct{}, limit int) []string {
-	out := make([]string, 0, len(values))
-	for v := range values {
-		out = append(out, v)
-	}
-	sort.Strings(out)
-	if len(out) > limit {
-		return out[:limit]
-	}
-	return out
-}

+ 23 - 16
pkg/allocation/autocompletequeryservice_test.go

@@ -4,6 +4,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/opencost/opencost/core/pkg/autocomplete"
 	"github.com/opencost/opencost/core/pkg/opencost"
 )
 
@@ -34,10 +35,12 @@ func TestQueryAllocationAutocompleteFromSetRange(t *testing.T) {
 	}))
 
 	asr := opencost.NewAllocationSetRange(as)
+	window := opencost.NewClosedWindow(start, start.Add(24*time.Hour))
 
-	resp, err := QueryAllocationAutocompleteFromSetRange(asr, AllocationAutocompleteRequest{
-		Field: "label",
-		Limit: 10,
+	resp, err := QueryAllocationAutocompleteFromSetRange(asr, autocomplete.Request{
+		Field:  "label",
+		Limit:  10,
+		Window: window,
 	})
 	if err != nil {
 		t.Fatalf("unexpected error: %v", err)
@@ -46,9 +49,10 @@ func TestQueryAllocationAutocompleteFromSetRange(t *testing.T) {
 		t.Fatalf("unexpected label autocomplete response: %+v", resp.Data)
 	}
 
-	valueResp, err := QueryAllocationAutocompleteFromSetRange(asr, AllocationAutocompleteRequest{
+	valueResp, err := QueryAllocationAutocompleteFromSetRange(asr, autocomplete.Request{
 		Field:  "label:team",
 		Search: "plat",
+		Window: window,
 	})
 	if err != nil {
 		t.Fatalf("unexpected error: %v", err)
@@ -57,8 +61,9 @@ func TestQueryAllocationAutocompleteFromSetRange(t *testing.T) {
 		t.Fatalf("unexpected label value autocomplete response: %+v", valueResp.Data)
 	}
 
-	mixedCaseResp, err := QueryAllocationAutocompleteFromSetRange(asr, AllocationAutocompleteRequest{
-		Field: "label:Team",
+	mixedCaseResp, err := QueryAllocationAutocompleteFromSetRange(asr, autocomplete.Request{
+		Field:  "label:Team",
+		Window: window,
 	})
 	if err != nil {
 		t.Fatalf("unexpected error: %v", err)
@@ -67,24 +72,26 @@ func TestQueryAllocationAutocompleteFromSetRange(t *testing.T) {
 		t.Fatalf("expected label:team to match Team label values, got %+v", mixedCaseResp.Data)
 	}
 
-	_, err = QueryAllocationAutocompleteFromSetRange(asr, AllocationAutocompleteRequest{
-		Field: "account",
+	accountResp, err := QueryAllocationAutocompleteFromSetRange(asr, autocomplete.Request{
+		Field:  "account",
+		Window: window,
 	})
-	if err == nil {
-		t.Fatal("expected error for unsupported account field")
+	if err != nil {
+		t.Fatalf("unexpected error for account field: %v", err)
 	}
-	if !IsAutocompleteBadRequest(err) {
-		t.Fatalf("expected bad request error, got: %v", err)
+	if len(accountResp.Data) != 0 {
+		t.Fatalf("expected empty account autocomplete response, got %+v", accountResp.Data)
 	}
 
-	_, err = QueryAllocationAutocompleteFromSetRange(asr, AllocationAutocompleteRequest{
-		Field: "namespace",
-		Limit: MaxAutocompleteResultLimit + 1,
+	_, err = QueryAllocationAutocompleteFromSetRange(asr, autocomplete.Request{
+		Field:  "namespace",
+		Limit:  autocomplete.MaxResultLimit + 1,
+		Window: window,
 	})
 	if err == nil {
 		t.Fatal("expected error for excessive limit")
 	}
-	if !IsAutocompleteBadRequest(err) {
+	if !autocomplete.IsBadRequest(err) {
 		t.Fatalf("expected bad request error, got: %v", err)
 	}
 }

+ 44 - 0
pkg/asset/autocomplete_normalize_test.go

@@ -0,0 +1,44 @@
+package asset
+
+import (
+	"errors"
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+	coreasset "github.com/opencost/opencost/core/pkg/autocomplete/asset"
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+func TestNormalizeAssetAutocompleteRequest(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(24 * time.Hour)
+	req := &autocomplete.Request{
+		TenantID: "t1",
+		Field:    "assettype",
+		Search:   " x ",
+		Limit:    0,
+		Window:   opencost.NewClosedWindow(start, end),
+	}
+	opts := autocomplete.NormalizeOptions{RequireTenantID: true, WindowValidator: coreasset.ValidateWindow}
+	field, err := autocomplete.NormalizeRequest(req, coreasset.ValidateField, opts)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if field != "type" || req.Search != "x" || req.Limit != autocomplete.DefaultResultLimit {
+		t.Fatalf("unexpected normalized request: field=%s search=%q limit=%d", field, req.Search, req.Limit)
+	}
+
+	_, err = autocomplete.NormalizeRequest(&autocomplete.Request{
+		TenantID: "t1",
+		Field:    "cluster",
+		Limit:    autocomplete.MaxResultLimit + 1,
+		Window:   opencost.NewClosedWindow(start, end),
+	}, coreasset.ValidateField, opts)
+	if err == nil || !autocomplete.IsBadRequest(err) {
+		t.Fatalf("expected bad request, got %v", err)
+	}
+	if !errors.Is(err, autocomplete.ErrBadRequest) {
+		t.Fatalf("expected ErrBadRequest")
+	}
+}

+ 75 - 0
pkg/asset/autocomplete_parser_test.go

@@ -0,0 +1,75 @@
+package asset
+
+import (
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+	coreasset "github.com/opencost/opencost/core/pkg/autocomplete/asset"
+	"github.com/opencost/opencost/core/pkg/util/httputil"
+)
+
+func TestParseAssetAutocompleteRequest(t *testing.T) {
+	windowStr := "2023-01-01T00:00:00Z,2023-01-02T00:00:00Z"
+	tests := map[string]struct {
+		values  map[string][]string
+		wantErr bool
+	}{
+		"missing window": {
+			values:  map[string][]string{"field": {"cluster"}},
+			wantErr: true,
+		},
+		"valid with default window": {
+			values: map[string][]string{
+				"field": {"cluster"},
+			},
+			wantErr: false,
+		},
+		"valid": {
+			values: map[string][]string{
+				"window": {windowStr},
+				"field":  {"label:App"},
+				"search": {"  foo  "},
+			},
+			wantErr: false,
+		},
+		"assettype alias": {
+			values: map[string][]string{
+				"window": {windowStr},
+				"field":  {"assettype"},
+			},
+			wantErr: false,
+		},
+	}
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			qp := httputil.NewQueryParams(tt.values)
+			opts := autocomplete.ParseOptions{DefaultTenantID: "tenant-1"}
+			if name == "valid with default window" {
+				opts.DefaultWindow = "30d"
+			}
+			got, err := coreasset.ParseRequest(qp, opts)
+			if (err != nil) != tt.wantErr {
+				t.Fatalf("ParseRequest() error = %v, wantErr %v", err, tt.wantErr)
+			}
+			if tt.wantErr {
+				return
+			}
+			if got.Field == "assettype" {
+				t.Fatalf("field should be normalized from assettype")
+			}
+			if name == "valid" && got.Search != "foo" {
+				t.Fatalf("search = %q, want foo", got.Search)
+			}
+			if name == "assettype alias" && got.Field != "type" {
+				t.Fatalf("field = %q, want type", got.Field)
+			}
+		})
+	}
+}
+
+func TestValidateAutocompleteField_assettype(t *testing.T) {
+	got, err := coreasset.ValidateField("assettype")
+	if err != nil || got != "type" {
+		t.Fatalf("ValidateField(assettype) = %q, %v", got, err)
+	}
+}

+ 14 - 0
pkg/asset/autocomplete_static_test.go

@@ -0,0 +1,14 @@
+package asset
+
+import (
+	"testing"
+
+	coreasset "github.com/opencost/opencost/core/pkg/autocomplete/asset"
+)
+
+func TestFilterStaticAutocompleteValues(t *testing.T) {
+	got := coreasset.FilterStaticValues(coreasset.StaticTypes(), "node")
+	if len(got) != 1 || got[0] != "node" {
+		t.Fatalf("FilterStaticValues() = %v", got)
+	}
+}

+ 24 - 94
pkg/asset/autocompletequeryservice.go

@@ -1,63 +1,39 @@
 package asset
 
 import (
-	"context"
-	"errors"
 	"fmt"
-	"sort"
 	"strings"
 
-	"github.com/opencost/opencost/core/pkg/filter"
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+	coreasset "github.com/opencost/opencost/core/pkg/autocomplete/asset"
 	"github.com/opencost/opencost/core/pkg/opencost"
 )
 
-// ErrAutocompleteBadRequest indicates a client error in an autocomplete request.
-var ErrAutocompleteBadRequest = errors.New("autocomplete bad request")
-
-// IsAutocompleteBadRequest reports whether err is a client validation error.
-func IsAutocompleteBadRequest(err error) bool {
-	return errors.Is(err, ErrAutocompleteBadRequest)
-}
-
-const DefaultAutocompleteResultLimit = 100
-const MaxAutocompleteResultLimit = 1000
-
-type AssetAutocompleteRequest struct {
-	TenantID string
-	Search   string
-	Field    string
-	Limit    int
-	Window   opencost.Window
-	Filter   filter.Filter
-}
-
-type AssetAutocompleteResponse struct {
-	Data []string `json:"data"`
-}
-
-type AutocompleteQueryService interface {
-	QueryAssetAutocomplete(AssetAutocompleteRequest, context.Context) (*AssetAutocompleteResponse, error)
-}
-
-func QueryAssetAutocompleteFromSet(assetSet *opencost.AssetSet, req AssetAutocompleteRequest) (*AssetAutocompleteResponse, error) {
-	if err := validateAssetAutocompleteWindow(req.Window); err != nil {
+func QueryAssetAutocompleteFromSet(assetSet *opencost.AssetSet, req autocomplete.Request) (*autocomplete.Response, error) {
+	field, err := autocomplete.NormalizeRequest(&req, coreasset.ValidateField, autocomplete.NormalizeOptions{
+		RequireTenantID: true,
+		WindowValidator: coreasset.ValidateWindow,
+	})
+	if err != nil {
 		return nil, err
 	}
-	if req.TenantID == "" {
-		return nil, fmt.Errorf("%w: tenant ID is required", ErrAutocompleteBadRequest)
-	}
 
-	field, err := validateAutocompleteField(req.Field)
+	route, _, err := coreasset.RouteField(field)
 	if err != nil {
-		return nil, fmt.Errorf("%w: invalid field: %w", ErrAutocompleteBadRequest, err)
+		return nil, fmt.Errorf("%w: %w", autocomplete.ErrBadRequest, err)
 	}
 
-	limit := req.Limit
-	if limit <= 0 {
-		limit = DefaultAutocompleteResultLimit
-	}
-	if limit > MaxAutocompleteResultLimit {
-		return nil, fmt.Errorf("%w: exceeded maximum autocomplete result limit of %d", ErrAutocompleteBadRequest, MaxAutocompleteResultLimit)
+	switch route {
+	case coreasset.RouteStaticType:
+		return &autocomplete.Response{Data: autocomplete.UniqueSortedLimited(
+			autocomplete.ToSet(coreasset.FilterStaticValues(coreasset.StaticTypes(), req.Search)),
+			req.Limit,
+		)}, nil
+	case coreasset.RouteStaticCategory:
+		return &autocomplete.Response{Data: autocomplete.UniqueSortedLimited(
+			autocomplete.ToSet(coreasset.FilterStaticValues(coreasset.StaticCategories(), req.Search)),
+			req.Limit,
+		)}, nil
 	}
 
 	var matcher opencost.AssetMatcher
@@ -65,7 +41,7 @@ func QueryAssetAutocompleteFromSet(assetSet *opencost.AssetSet, req AssetAutocom
 		compiler := opencost.NewAssetMatchCompiler()
 		matcher, err = compiler.Compile(req.Filter)
 		if err != nil {
-			return nil, fmt.Errorf("%w: failed to compile filter: %w", ErrAutocompleteBadRequest, err)
+			return nil, fmt.Errorf("%w: failed to compile filter: %w", autocomplete.ErrBadRequest, err)
 		}
 	}
 
@@ -91,41 +67,7 @@ func QueryAssetAutocompleteFromSet(assetSet *opencost.AssetSet, req AssetAutocom
 		}
 	}
 
-	data := make([]string, 0, len(results))
-	for value := range results {
-		data = append(data, value)
-	}
-	sort.Strings(data)
-	if len(data) > limit {
-		data = data[:limit]
-	}
-	return &AssetAutocompleteResponse{Data: data}, nil
-}
-
-func validateAutocompleteField(field string) (string, error) {
-	f := strings.ToLower(field)
-	switch f {
-	case "account", "cluster", "name", "provider", "providerid", "type", "category":
-		return f, nil
-	}
-	if f == "label" {
-		return f, nil
-	}
-	if strings.HasPrefix(f, "label:") {
-		_, labelKey, _ := strings.Cut(f, ":")
-		return "label:" + labelKey, nil
-	}
-	return "", fmt.Errorf("unrecognized field: %s", field)
-}
-
-func validateAssetAutocompleteWindow(window opencost.Window) error {
-	if window.IsOpen() {
-		return fmt.Errorf("%w: invalid window: %s", ErrAutocompleteBadRequest, window.String())
-	}
-	if window.Start() == nil || window.End() == nil {
-		return fmt.Errorf("%w: invalid window: missing start or end", ErrAutocompleteBadRequest)
-	}
-	return nil
+	return &autocomplete.Response{Data: autocomplete.UniqueSortedLimited(results, req.Limit)}, nil
 }
 
 func assetAutocompleteValues(asset opencost.Asset, field string) []string {
@@ -156,21 +98,9 @@ func assetAutocompleteValues(asset opencost.Asset, field string) []string {
 		return keys
 	case strings.HasPrefix(field, "label:"):
 		labelName := strings.TrimPrefix(field, "label:")
-		if value, ok := mapValueFold(asset.GetLabels(), labelName); ok {
+		if value, ok := autocomplete.MapValueFold(asset.GetLabels(), labelName); ok {
 			return []string{value}
 		}
 	}
 	return nil
 }
-
-func mapValueFold(values map[string]string, key string) (string, bool) {
-	if v, ok := values[key]; ok {
-		return v, true
-	}
-	for k, v := range values {
-		if strings.EqualFold(k, key) {
-			return v, true
-		}
-	}
-	return "", false
-}

+ 9 - 8
pkg/asset/autocompletequeryservice_test.go

@@ -4,6 +4,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/opencost/opencost/core/pkg/autocomplete"
 	"github.com/opencost/opencost/core/pkg/opencost"
 )
 
@@ -24,7 +25,7 @@ func TestQueryAssetAutocompleteFromSet(t *testing.T) {
 
 	assetSet := opencost.NewAssetSet(start, end, nodeA, nodeB)
 
-	resp, err := QueryAssetAutocompleteFromSet(assetSet, AssetAutocompleteRequest{
+	resp, err := QueryAssetAutocompleteFromSet(assetSet, autocomplete.Request{
 		TenantID: "opencost",
 		Field:    "cluster",
 		Window:   window,
@@ -36,7 +37,7 @@ func TestQueryAssetAutocompleteFromSet(t *testing.T) {
 		t.Fatalf("unexpected cluster autocomplete response: %+v", resp.Data)
 	}
 
-	labelResp, err := QueryAssetAutocompleteFromSet(assetSet, AssetAutocompleteRequest{
+	labelResp, err := QueryAssetAutocompleteFromSet(assetSet, autocomplete.Request{
 		TenantID: "opencost",
 		Field:    "label:team",
 		Search:   "plat",
@@ -49,17 +50,17 @@ func TestQueryAssetAutocompleteFromSet(t *testing.T) {
 		t.Fatalf("unexpected label autocomplete response: %+v", labelResp.Data)
 	}
 
-	_, err = QueryAssetAutocompleteFromSet(assetSet, AssetAutocompleteRequest{
+	_, err = QueryAssetAutocompleteFromSet(assetSet, autocomplete.Request{
 		Field: "cluster",
 	})
 	if err == nil {
 		t.Fatal("expected error when tenant ID is missing")
 	}
-	if !IsAutocompleteBadRequest(err) {
+	if !autocomplete.IsBadRequest(err) {
 		t.Fatalf("expected bad request error, got: %v", err)
 	}
 
-	_, err = QueryAssetAutocompleteFromSet(assetSet, AssetAutocompleteRequest{
+	_, err = QueryAssetAutocompleteFromSet(assetSet, autocomplete.Request{
 		TenantID: "opencost",
 		Field:    "labels",
 		Window:   window,
@@ -67,12 +68,12 @@ func TestQueryAssetAutocompleteFromSet(t *testing.T) {
 	if err == nil {
 		t.Fatal("expected error for invalid field prefix")
 	}
-	if !IsAutocompleteBadRequest(err) {
+	if !autocomplete.IsBadRequest(err) {
 		t.Fatalf("expected bad request error, got: %v", err)
 	}
 
 	openWindow := opencost.NewWindow(&start, nil)
-	_, err = QueryAssetAutocompleteFromSet(assetSet, AssetAutocompleteRequest{
+	_, err = QueryAssetAutocompleteFromSet(assetSet, autocomplete.Request{
 		TenantID: "opencost",
 		Field:    "name",
 		Window:   openWindow,
@@ -80,7 +81,7 @@ func TestQueryAssetAutocompleteFromSet(t *testing.T) {
 	if err == nil {
 		t.Fatal("expected error for open window")
 	}
-	if !IsAutocompleteBadRequest(err) {
+	if !autocomplete.IsBadRequest(err) {
 		t.Fatalf("expected bad request error, got: %v", err)
 	}
 }

+ 33 - 6
pkg/cloud/gcp/bigqueryconfiguration.go

@@ -17,6 +17,7 @@ type BigQueryConfiguration struct {
 	Table                string     `json:"table"`
 	ExcludePartitionTime bool       `json:"excludePartitionTime"`
 	Location             string     `json:"location"`
+	QueryProjectID       string     `json:"queryProjectID"`
 	Authorizer           Authorizer `json:"authorizer"`
 }
 
@@ -81,16 +82,29 @@ func (bqc *BigQueryConfiguration) Equals(config cloud.Config) bool {
 		return false
 	}
 
+	bqcEffective := bqc.QueryProjectID
+	if bqcEffective == "" {
+		bqcEffective = bqc.ProjectID
+	}
+	thatEffective := thatConfig.QueryProjectID
+	if thatEffective == "" {
+		thatEffective = thatConfig.ProjectID
+	}
+	if bqcEffective != thatEffective {
+		return false
+	}
+
 	return true
 }
 
 func (bqc *BigQueryConfiguration) Sanitize() cloud.Config {
 	return &BigQueryConfiguration{
-		ProjectID:  bqc.ProjectID,
-		Dataset:    bqc.Dataset,
-		Table:      bqc.Table,
-		Location:   bqc.Location,
-		Authorizer: bqc.Authorizer.Sanitize().(Authorizer),
+		ProjectID:      bqc.ProjectID,
+		Dataset:        bqc.Dataset,
+		Table:          bqc.Table,
+		Location:       bqc.Location,
+		QueryProjectID: bqc.QueryProjectID,
+		Authorizer:     bqc.Authorizer.Sanitize().(Authorizer),
 	}
 }
 
@@ -113,7 +127,12 @@ func (bqc *BigQueryConfiguration) GetBigQueryClient(ctx context.Context) (*bigqu
 		return nil, err
 	}
 
-	client, err := bigquery.NewClient(ctx, bqc.ProjectID, clientOpts...)
+	queryProjectID := bqc.QueryProjectID
+	if queryProjectID == "" {
+		queryProjectID = bqc.ProjectID
+	}
+
+	client, err := bigquery.NewClient(ctx, queryProjectID, clientOpts...)
 	if err != nil {
 		return nil, err
 	}
@@ -159,6 +178,14 @@ func (bqc *BigQueryConfiguration) UnmarshalJSON(b []byte) error {
 		bqc.Location = location
 	}
 
+	if _, ok := fmap["queryProjectID"]; ok {
+		queryProjectID, err := cloud.GetInterfaceValue[string](fmap, "queryProjectID")
+		if err != nil {
+			return fmt.Errorf("BigQueryConfiguration: UnmarshalJSON: %w", err)
+		}
+		bqc.QueryProjectID = queryProjectID
+	}
+
 	authAny, ok := fmap["authorizer"]
 	if !ok {
 		return fmt.Errorf("StorageConfiguration: UnmarshalJSON: missing authorizer")

+ 58 - 2
pkg/cloud/gcp/bigqueryconfiguration_test.go

@@ -138,7 +138,8 @@ func TestBigQueryConfiguration_Equals(t *testing.T) {
 						"key1": "key2",
 					},
 				},
-				Location: "EU",
+				Location:       "EU",
+				QueryProjectID: "queryProjectID",
 			},
 			right: &BigQueryConfiguration{
 				ProjectID: "projectID",
@@ -150,7 +151,8 @@ func TestBigQueryConfiguration_Equals(t *testing.T) {
 						"key1": "key2",
 					},
 				},
-				Location: "EU",
+				Location:       "EU",
+				QueryProjectID: "queryProjectID",
 			},
 			expected: true,
 		},
@@ -347,6 +349,60 @@ func TestBigQueryConfiguration_Equals(t *testing.T) {
 			},
 			expected: false,
 		},
+		"different queryProjectID": {
+			left: BigQueryConfiguration{
+				ProjectID:      "projectID",
+				Dataset:        "dataset",
+				Table:          "table",
+				QueryProjectID: "queryProjectID",
+				Authorizer: &ServiceAccountKey{
+					Key: map[string]string{
+						"Key":  "Key",
+						"key1": "key2",
+					},
+				},
+			},
+			right: &BigQueryConfiguration{
+				ProjectID:      "projectID",
+				Dataset:        "dataset",
+				Table:          "table",
+				QueryProjectID: "queryProjectID2",
+				Authorizer: &ServiceAccountKey{
+					Key: map[string]string{
+						"Key":  "Key",
+						"key1": "key2",
+					},
+				},
+			},
+			expected: false,
+		},
+		"empty queryProjectID equals projectID": {
+			left: BigQueryConfiguration{
+				ProjectID:      "projectID",
+				Dataset:        "dataset",
+				Table:          "table",
+				QueryProjectID: "",
+				Authorizer: &ServiceAccountKey{
+					Key: map[string]string{
+						"Key":  "Key",
+						"key1": "key2",
+					},
+				},
+			},
+			right: &BigQueryConfiguration{
+				ProjectID:      "projectID",
+				Dataset:        "dataset",
+				Table:          "table",
+				QueryProjectID: "projectID",
+				Authorizer: &ServiceAccountKey{
+					Key: map[string]string{
+						"Key":  "Key",
+						"key1": "key2",
+					},
+				},
+			},
+			expected: true,
+		},
 	}
 
 	for name, testCase := range testCases {

+ 28 - 6
pkg/cloud/provider/customprovider.go

@@ -73,6 +73,8 @@ type customProviderKey struct {
 	SpotLabelValue string
 	GPULabel       string
 	GPULabelValue  string
+	GPUTypeName    string
+	GPUCountValue  int
 	Labels         map[string]string
 }
 
@@ -179,8 +181,12 @@ func (cp *CustomProvider) NodePricing(key models.Key) (*models.Node, models.Pric
 		k = "default"
 	}
 	if key.GPUType() != "" {
-		k += ",gpu"    // TODO: support multiple custom gpu types.
-		gpuCount = "1" // TODO: support more than one gpu.
+		k += ",gpu" // TODO: support multiple custom gpu types.
+		if key.GPUCount() > 0 {
+			gpuCount = strconv.Itoa(key.GPUCount())
+		} else {
+			gpuCount = "1"
+		}
 	}
 
 	var cpuCost, ramCost, gpuCost string
@@ -236,11 +242,25 @@ func (cp *CustomProvider) DownloadPricingData() error {
 }
 
 func (cp *CustomProvider) GetKey(labels map[string]string, n *clustercache.Node) models.Key {
+	gpuTypeName := ""
+	gpuCount := 0
+	if n != nil {
+		if gpu, ok := n.Status.Capacity["nvidia.com/gpu"]; ok && gpu.Value() > 0 {
+			gpuTypeName = "nvidia.com/gpu"
+			gpuCount = int(gpu.Value())
+		} else if vgpu, ok := n.Status.Capacity["k8s.amazonaws.com/vgpu"]; ok && vgpu.Value() > 0 {
+			gpuTypeName = "k8s.amazonaws.com/vgpu"
+			gpuCount = int(vgpu.Value())
+		}
+	}
+
 	return &customProviderKey{
 		SpotLabel:      cp.SpotLabel,
 		SpotLabelValue: cp.SpotLabelValue,
 		GPULabel:       cp.GPULabel,
 		GPULabelValue:  cp.GPULabelValue,
+		GPUTypeName:    gpuTypeName,
+		GPUCountValue:  gpuCount,
 		Labels:         labels,
 	}
 }
@@ -375,14 +395,16 @@ func (key *customPVKey) Features() string {
 }
 
 func (k *customProviderKey) GPUCount() int {
-	return 0
+	return k.GPUCountValue
 }
 
 func (cpk *customProviderKey) GPUType() string {
-	if t, ok := cpk.Labels[cpk.GPULabel]; ok {
-		return t
+	if cpk.GPULabel != "" {
+		if t, ok := cpk.Labels[cpk.GPULabel]; ok {
+			return t
+		}
 	}
-	return ""
+	return cpk.GPUTypeName
 }
 
 func (cpk *customProviderKey) ID() string {

+ 163 - 0
pkg/cloud/provider/provider_test.go

@@ -2,6 +2,12 @@ package provider
 
 import (
 	"testing"
+
+	"github.com/opencost/opencost/core/pkg/clustercache"
+	"github.com/opencost/opencost/core/pkg/storage"
+	"github.com/opencost/opencost/pkg/config"
+	v1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/resource"
 )
 
 func TestParseLocalDiskID(t *testing.T) {
@@ -42,3 +48,160 @@ func TestParseLocalDiskID(t *testing.T) {
 		})
 	}
 }
+
+func TestProviderConfigUpdateFromMapPreservesHourlyPrices(t *testing.T) {
+	confMan := config.NewConfigFileManager(storage.NewMemoryStorage())
+	providerConfig := NewProviderConfig(confMan, "default.json")
+
+	updated, err := providerConfig.UpdateFromMap(map[string]string{
+		"CPU":     "0.031611",
+		"spotCPU": "0.006655",
+		"RAM":     "0.004237",
+		"spotRAM": "0.000892",
+		"GPU":     "0.95",
+		"spotGPU": "0.308",
+		"storage": "0.00005479452",
+	})
+	if err != nil {
+		t.Fatalf("UpdateFromMap returned error: %v", err)
+	}
+
+	if updated.CPU != "0.031611" {
+		t.Errorf("CPU = %q, want hourly value %q", updated.CPU, "0.031611")
+	}
+	if updated.SpotCPU != "0.006655" {
+		t.Errorf("SpotCPU = %q, want hourly value %q", updated.SpotCPU, "0.006655")
+	}
+	if updated.RAM != "0.004237" {
+		t.Errorf("RAM = %q, want hourly value %q", updated.RAM, "0.004237")
+	}
+	if updated.SpotRAM != "0.000892" {
+		t.Errorf("SpotRAM = %q, want hourly value %q", updated.SpotRAM, "0.000892")
+	}
+	if updated.GPU != "0.95" {
+		t.Errorf("GPU = %q, want hourly value %q", updated.GPU, "0.95")
+	}
+	if updated.SpotGPU != "0.308" {
+		t.Errorf("SpotGPU = %q, want hourly value %q", updated.SpotGPU, "0.308")
+	}
+	if updated.Storage != "0.00005479452" {
+		t.Errorf("Storage = %q, want hourly value %q", updated.Storage, "0.00005479452")
+	}
+}
+
+func TestCustomProviderGetKeyDetectsGPUCapacity(t *testing.T) {
+	cases := []struct {
+		name         string
+		provider     *CustomProvider
+		labels       map[string]string
+		capacity     v1.ResourceList
+		wantGPUType  string
+		wantGPUCount int
+	}{
+		{
+			name: "nvidia GPU capacity",
+			capacity: v1.ResourceList{
+				"nvidia.com/gpu": resource.MustParse("2"),
+			},
+			wantGPUType:  "nvidia.com/gpu",
+			wantGPUCount: 2,
+		},
+		{
+			name: "virtual GPU capacity",
+			capacity: v1.ResourceList{
+				"k8s.amazonaws.com/vgpu": resource.MustParse("3"),
+			},
+			wantGPUType:  "k8s.amazonaws.com/vgpu",
+			wantGPUCount: 3,
+		},
+		{
+			name: "configured GPU label takes precedence over capacity type",
+			provider: &CustomProvider{
+				GPULabel: "gpu.example/type",
+			},
+			labels: map[string]string{
+				"gpu.example/type": "a100",
+			},
+			capacity: v1.ResourceList{
+				"nvidia.com/gpu": resource.MustParse("4"),
+			},
+			wantGPUType:  "a100",
+			wantGPUCount: 4,
+		},
+		{
+			name:         "no GPU capacity",
+			capacity:     v1.ResourceList{},
+			wantGPUType:  "",
+			wantGPUCount: 0,
+		},
+	}
+
+	for _, tt := range cases {
+		t.Run(tt.name, func(t *testing.T) {
+			customProvider := tt.provider
+			if customProvider == nil {
+				customProvider = &CustomProvider{}
+			}
+			labels := tt.labels
+			if labels == nil {
+				labels = map[string]string{}
+			}
+
+			key := customProvider.GetKey(labels, &clustercache.Node{
+				Labels: labels,
+				Status: v1.NodeStatus{
+					Capacity: tt.capacity,
+				},
+			})
+
+			if got := key.GPUType(); got != tt.wantGPUType {
+				t.Errorf("GPUType() = %q, want %q", got, tt.wantGPUType)
+			}
+			if got := key.GPUCount(); got != tt.wantGPUCount {
+				t.Errorf("GPUCount() = %d, want %d", got, tt.wantGPUCount)
+			}
+		})
+	}
+}
+
+func TestCustomProviderNodePricingUsesDetectedGPUCount(t *testing.T) {
+	customProvider := &CustomProvider{
+		Pricing: map[string]*NodePrice{
+			"default": {
+				CPU: "0.031611",
+				RAM: "0.004237",
+			},
+			"default,gpu": {
+				CPU: "0.031611",
+				RAM: "0.004237",
+				GPU: "0.95",
+			},
+		},
+	}
+
+	key := customProvider.GetKey(map[string]string{}, &clustercache.Node{
+		Status: v1.NodeStatus{
+			Capacity: v1.ResourceList{
+				"nvidia.com/gpu": resource.MustParse("2"),
+			},
+		},
+	})
+
+	node, _, err := customProvider.NodePricing(key)
+	if err != nil {
+		t.Fatalf("NodePricing returned error: %v", err)
+	}
+
+	if node.VCPUCost != "0.031611" {
+		t.Errorf("VCPUCost = %q, want %q", node.VCPUCost, "0.031611")
+	}
+	if node.RAMCost != "0.004237" {
+		t.Errorf("RAMCost = %q, want %q", node.RAMCost, "0.004237")
+	}
+	if node.GPUCost != "0.95" {
+		t.Errorf("GPUCost = %q, want %q", node.GPUCost, "0.95")
+	}
+	if node.GPU != "2" {
+		t.Errorf("GPU = %q, want %q", node.GPU, "2")
+	}
+}

+ 0 - 8
pkg/cloud/provider/providerconfig.go

@@ -4,7 +4,6 @@ import (
 	"fmt"
 	"os"
 	gopath "path"
-	"strconv"
 	"sync"
 
 	coreenv "github.com/opencost/opencost/core/pkg/env"
@@ -191,13 +190,6 @@ func (pc *ProviderConfig) UpdateFromMap(a map[string]string) (*models.CustomPric
 		for k, v := range a {
 			// Just so we consistently supply / receive the same values, uppercase the first letter.
 			kUpper := utils.ToTitle.String(k)
-			if kUpper == "CPU" || kUpper == "SpotCPU" || kUpper == "RAM" || kUpper == "SpotRAM" || kUpper == "GPU" || kUpper == "Storage" {
-				val, err := strconv.ParseFloat(v, 64)
-				if err != nil {
-					return fmt.Errorf("unable to parse CPU from string to float: %s", err.Error())
-				}
-				v = fmt.Sprintf("%f", val/730)
-			}
 
 			err := models.SetCustomPricingField(c, kUpper, v)
 			if err != nil {

+ 9 - 8
pkg/cloudcost/autocomplete_test.go

@@ -5,6 +5,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/opencost/opencost/core/pkg/autocomplete"
 	"github.com/opencost/opencost/core/pkg/opencost"
 )
 
@@ -19,7 +20,7 @@ func TestRepositoryQuerier_QueryCloudCostAutocomplete(t *testing.T) {
 	}
 	rq := NewRepositoryQuerier(repo)
 
-	resp, err := rq.QueryCloudCostAutocomplete(context.Background(), CloudCostAutocompleteRequest{
+	resp, err := rq.QueryCloudCostAutocomplete(context.Background(), autocomplete.Request{
 		Field:  opencost.CloudCostServiceProp,
 		Window: opencost.NewClosedWindow(start, end),
 	})
@@ -30,7 +31,7 @@ func TestRepositoryQuerier_QueryCloudCostAutocomplete(t *testing.T) {
 		t.Fatalf("expected 2 service values, got %d: %+v", len(resp.Data), resp.Data)
 	}
 
-	labelResp, err := rq.QueryCloudCostAutocomplete(context.Background(), CloudCostAutocompleteRequest{
+	labelResp, err := rq.QueryCloudCostAutocomplete(context.Background(), autocomplete.Request{
 		Field:  "label:label1",
 		Search: "value1",
 		Window: opencost.NewClosedWindow(start, end),
@@ -42,30 +43,30 @@ func TestRepositoryQuerier_QueryCloudCostAutocomplete(t *testing.T) {
 		t.Fatalf("unexpected label autocomplete response: %+v", labelResp.Data)
 	}
 
-	_, err = rq.QueryCloudCostAutocomplete(context.Background(), CloudCostAutocompleteRequest{
+	_, err = rq.QueryCloudCostAutocomplete(context.Background(), autocomplete.Request{
 		Field:  opencost.CloudCostServiceProp,
-		Limit:  MaxAutocompleteResultLimit + 1,
+		Limit:  autocomplete.MaxResultLimit + 1,
 		Window: opencost.NewClosedWindow(start, end),
 	})
 	if err == nil {
 		t.Fatal("expected error for excessive limit")
 	}
-	if !IsAutocompleteBadRequest(err) {
+	if !autocomplete.IsBadRequest(err) {
 		t.Fatalf("expected bad request error, got: %v", err)
 	}
 
-	_, err = rq.QueryCloudCostAutocomplete(context.Background(), CloudCostAutocompleteRequest{
+	_, err = rq.QueryCloudCostAutocomplete(context.Background(), autocomplete.Request{
 		Field:  "not-a-real-field",
 		Window: opencost.NewClosedWindow(start, end),
 	})
 	if err == nil {
 		t.Fatal("expected error for invalid field")
 	}
-	if !IsAutocompleteBadRequest(err) {
+	if !autocomplete.IsBadRequest(err) {
 		t.Fatalf("expected bad request error, got: %v", err)
 	}
 
-	mixedCaseResp, err := rq.QueryCloudCostAutocomplete(context.Background(), CloudCostAutocompleteRequest{
+	mixedCaseResp, err := rq.QueryCloudCostAutocomplete(context.Background(), autocomplete.Request{
 		Field:  "label:Label1",
 		Search: "value1",
 		Window: opencost.NewClosedWindow(start, end),

+ 2 - 16
pkg/cloudcost/querier.go

@@ -6,6 +6,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/opencost/opencost/core/pkg/autocomplete"
 	"github.com/opencost/opencost/core/pkg/filter"
 	"github.com/opencost/opencost/core/pkg/opencost"
 )
@@ -13,7 +14,7 @@ import (
 // Querier allows for querying ranges of CloudCost data
 type Querier interface {
 	Query(context.Context, QueryRequest) (*opencost.CloudCostSetRange, error)
-	QueryCloudCostAutocomplete(context.Context, CloudCostAutocompleteRequest) (*CloudCostAutocompleteResponse, error)
+	QueryCloudCostAutocomplete(context.Context, autocomplete.Request) (*autocomplete.Response, error)
 }
 
 type QueryRequest struct {
@@ -24,21 +25,6 @@ type QueryRequest struct {
 	Filter      filter.Filter
 }
 
-const DefaultAutocompleteResultLimit = 100
-const MaxAutocompleteResultLimit = 1000
-
-type CloudCostAutocompleteRequest struct {
-	Search string
-	Field  string
-	Limit  int
-	Window opencost.Window
-	Filter filter.Filter
-}
-
-type CloudCostAutocompleteResponse struct {
-	Data []string `json:"data"`
-}
-
 // DefaultChartItemsLength the default max number of items for a ViewGraphDataSet
 const DefaultChartItemsLength int = 10
 

+ 4 - 2
pkg/cloudcost/queryservice.go

@@ -7,6 +7,8 @@ import (
 	"strings"
 
 	"github.com/julienschmidt/httprouter"
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+	corecloudcost "github.com/opencost/opencost/core/pkg/autocomplete/cloudcost"
 	"github.com/opencost/opencost/core/pkg/opencost"
 	"github.com/opencost/opencost/core/pkg/util/httputil"
 	"go.opentelemetry.io/otel"
@@ -85,7 +87,7 @@ func (s *QueryService) GetCloudCostAutocompleteHandler() func(w http.ResponseWri
 		}
 
 		qp := httputil.NewQueryParams(r.URL.Query())
-		request, err := ParseCloudCostAutocompleteRequest(qp)
+		request, err := corecloudcost.ParseRequest(qp, autocomplete.ParseOptions{})
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusBadRequest)
 			return
@@ -93,7 +95,7 @@ func (s *QueryService) GetCloudCostAutocompleteHandler() func(w http.ResponseWri
 
 		resp, err := s.Querier.QueryCloudCostAutocomplete(ctx, *request)
 		if err != nil {
-			if errors.Is(err, ErrAutocompleteBadRequest) {
+			if errors.Is(err, autocomplete.ErrBadRequest) {
 				http.Error(w, err.Error(), http.StatusBadRequest)
 				return
 			}

+ 0 - 37
pkg/cloudcost/queryservice_helper.go

@@ -64,43 +64,6 @@ func ParseCloudCostRequest(qp httputil.QueryParams) (*QueryRequest, error) {
 	return opts, nil
 }
 
-func ParseCloudCostAutocompleteRequest(qp httputil.QueryParams) (*CloudCostAutocompleteRequest, error) {
-	windowStr := qp.Get("window", "")
-	if windowStr == "" {
-		return nil, fmt.Errorf("missing required 'window' parameter")
-	}
-
-	window, err := opencost.ParseWindowUTC(windowStr)
-	if err != nil {
-		return nil, fmt.Errorf("invalid window parameter: %w", err)
-	}
-	if window.IsOpen() {
-		return nil, fmt.Errorf("invalid window parameter: %s", window.String())
-	}
-
-	var parsedFilter filter.Filter
-	filterString := qp.Get("filter", "")
-	if filterString != "" {
-		parser := cloudcost.NewCloudCostFilterParser()
-		parsedFilter, err = parser.Parse(filterString)
-		if err != nil {
-			return nil, fmt.Errorf("invalid 'filter' parameter: %w", err)
-		}
-	}
-
-	req := &CloudCostAutocompleteRequest{
-		Search: qp.Get("search", ""),
-		Field:  qp.Get("field", ""),
-		Limit:  qp.GetInt("limit", 0),
-		Window: window,
-		Filter: parsedFilter,
-	}
-	if req.Field == "" {
-		return nil, fmt.Errorf("missing required 'field' parameter")
-	}
-	return req, nil
-}
-
 func ParseCloudCostViewRequest(qp httputil.QueryParams) (*ViewQueryRequest, error) {
 	qr, err := ParseCloudCostRequest(qp)
 	if err != nil {

+ 6 - 4
pkg/cloudcost/queryservice_helper_test.go

@@ -5,6 +5,8 @@ import (
 	"testing"
 	"time"
 
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+	corecloudcost "github.com/opencost/opencost/core/pkg/autocomplete/cloudcost"
 	"github.com/opencost/opencost/core/pkg/filter/cloudcost"
 	"github.com/opencost/opencost/core/pkg/opencost"
 	"github.com/opencost/opencost/core/pkg/util/httputil"
@@ -143,7 +145,7 @@ func TestParseCloudCostAutocompleteRequest(t *testing.T) {
 
 	tests := map[string]struct {
 		values  map[string][]string
-		want    *CloudCostAutocompleteRequest
+		want    *autocomplete.Request
 		wantErr bool
 	}{
 		"missing window": {
@@ -184,7 +186,7 @@ func TestParseCloudCostAutocompleteRequest(t *testing.T) {
 				"search": {"ec2"},
 				"limit":  {"25"},
 			},
-			want: &CloudCostAutocompleteRequest{
+			want: &autocomplete.Request{
 				Search: "ec2",
 				Field:  "service",
 				Limit:  25,
@@ -197,9 +199,9 @@ func TestParseCloudCostAutocompleteRequest(t *testing.T) {
 	for name, tt := range tests {
 		t.Run(name, func(t *testing.T) {
 			qp := httputil.NewQueryParams(tt.values)
-			got, err := ParseCloudCostAutocompleteRequest(qp)
+			got, err := corecloudcost.ParseRequest(qp, autocomplete.ParseOptions{})
 			if (err != nil) != tt.wantErr {
-				t.Fatalf("ParseCloudCostAutocompleteRequest() error = %v, wantErr %v", err, tt.wantErr)
+				t.Fatalf("ParseRequest() error = %v, wantErr %v", err, tt.wantErr)
 			}
 			if tt.wantErr {
 				return

+ 7 - 55
pkg/cloudcost/repositoryquerier.go

@@ -2,23 +2,16 @@ package cloudcost
 
 import (
 	"context"
-	"errors"
 	"fmt"
 	"sort"
 	"strings"
 
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+	corecloudcost "github.com/opencost/opencost/core/pkg/autocomplete/cloudcost"
 	"github.com/opencost/opencost/core/pkg/log"
 	"github.com/opencost/opencost/core/pkg/opencost"
 )
 
-// ErrAutocompleteBadRequest indicates a client error in an autocomplete request.
-var ErrAutocompleteBadRequest = errors.New("autocomplete bad request")
-
-// IsAutocompleteBadRequest reports whether err is a client validation error.
-func IsAutocompleteBadRequest(err error) bool {
-	return errors.Is(err, ErrAutocompleteBadRequest)
-}
-
 // RepositoryQuerier is an implementation of Querier and ViewQuerier which pulls directly from a Repository
 type RepositoryQuerier struct {
 	repo Repository
@@ -77,23 +70,12 @@ func (rq *RepositoryQuerier) Query(ctx context.Context, request QueryRequest) (*
 	return ccsr, nil
 }
 
-func (rq *RepositoryQuerier) QueryCloudCostAutocomplete(ctx context.Context, request CloudCostAutocompleteRequest) (*CloudCostAutocompleteResponse, error) {
-	if request.Window.IsOpen() {
-		return nil, fmt.Errorf("%w: invalid window for autocomplete query: %s", ErrAutocompleteBadRequest, request.Window.String())
-	}
-
-	limit := request.Limit
-	if limit <= 0 {
-		limit = DefaultAutocompleteResultLimit
-	}
-	if limit > MaxAutocompleteResultLimit {
-		return nil, fmt.Errorf("%w: exceeded maximum autocomplete result limit of %d", ErrAutocompleteBadRequest, MaxAutocompleteResultLimit)
-	}
-
-	field, err := validateCloudCostAutocompleteField(request.Field)
+func (rq *RepositoryQuerier) QueryCloudCostAutocomplete(ctx context.Context, request autocomplete.Request) (*autocomplete.Response, error) {
+	field, err := autocomplete.NormalizeRequest(&request, corecloudcost.ValidateField, autocomplete.NormalizeOptions{})
 	if err != nil {
-		return nil, fmt.Errorf("%w: invalid field: %w", ErrAutocompleteBadRequest, err)
+		return nil, err
 	}
+	limit := request.Limit
 
 	ccsr, err := rq.Query(ctx, QueryRequest{
 		Start:      *request.Window.Start(),
@@ -126,37 +108,7 @@ func (rq *RepositoryQuerier) QueryCloudCostAutocomplete(ctx context.Context, req
 		}
 	}
 
-	data := make([]string, 0, len(results))
-	for result := range results {
-		data = append(data, result)
-	}
-	sort.Strings(data)
-	if len(data) > limit {
-		data = data[:limit]
-	}
-
-	return &CloudCostAutocompleteResponse{Data: data}, nil
-}
-
-func validateCloudCostAutocompleteField(field string) (string, error) {
-	if field == "" {
-		return "", fmt.Errorf("field is required")
-	}
-
-	f := strings.ToLower(field)
-	if f == "label" {
-		return f, nil
-	}
-	if strings.HasPrefix(f, "label:") {
-		_, labelKey, _ := strings.Cut(f, ":")
-		return "label:" + labelKey, nil
-	}
-
-	property, err := opencost.ParseCloudCostProperty(field)
-	if err != nil {
-		return "", err
-	}
-	return string(property), nil
+	return &autocomplete.Response{Data: autocomplete.UniqueSortedLimited(results, limit)}, nil
 }
 
 func cloudCostAutocompleteValues(cc *opencost.CloudCost, field string) []string {

+ 22 - 53
pkg/costmodel/autocomplete.go

@@ -6,9 +6,9 @@ import (
 	"net/http"
 
 	"github.com/julienschmidt/httprouter"
-	"github.com/opencost/opencost/core/pkg/filter"
-	allocationfilter "github.com/opencost/opencost/core/pkg/filter/allocation"
-	assetfilter "github.com/opencost/opencost/core/pkg/filter/asset"
+	"github.com/opencost/opencost/core/pkg/autocomplete"
+	coreallocation "github.com/opencost/opencost/core/pkg/autocomplete/allocation"
+	coreasset "github.com/opencost/opencost/core/pkg/autocomplete/asset"
 	"github.com/opencost/opencost/core/pkg/opencost"
 	"github.com/opencost/opencost/core/pkg/util/httputil"
 	"github.com/opencost/opencost/pkg/allocation"
@@ -20,34 +20,21 @@ func (a *Accesses) ComputeAllocationAutocompleteHandler(w http.ResponseWriter, r
 	w.Header().Set("Content-Type", "application/json")
 	qp := httputil.NewQueryParams(r.URL.Query())
 
-	window, err := opencost.ParseWindowWithOffset(qp.Get("window", ""), env.GetParsedUTCOffset())
+	offset := env.GetParsedUTCOffset()
+	req, err := coreallocation.ParseRequest(qp, autocomplete.ParseOptions{
+		LabelConfig: opencost.NewLabelConfig(),
+		UTCOffset:   &offset,
+	})
 	if err != nil {
-		http.Error(w, fmt.Sprintf("Invalid 'window' parameter: %s", err), http.StatusBadRequest)
+		http.Error(w, fmt.Sprintf("Invalid allocation autocomplete request: %s", err), http.StatusBadRequest)
 		return
 	}
 
-	var parsedFilter filter.Filter
 	filterString := qp.Get("filter", "")
-	if filterString != "" {
-		parser := allocationfilter.NewAllocationFilterParser()
-		parsedFilter, err = parser.Parse(filterString)
-		if err != nil {
-			http.Error(w, fmt.Sprintf("Invalid 'filter' parameter: %s", err), http.StatusBadRequest)
-			return
-		}
-	}
-
-	resp, err := a.QueryAllocationAutocomplete(allocation.AllocationAutocompleteRequest{
-		Search:      qp.Get("search", ""),
-		Field:       qp.Get("field", ""),
-		Limit:       qp.GetInt("limit", 0),
-		Window:      window,
-		Filter:      parsedFilter,
-		LabelConfig: opencost.NewLabelConfig(),
-	}, filterString, r.Context())
+	resp, err := a.QueryAllocationAutocomplete(*req, filterString, r.Context())
 	if err != nil {
 		status := http.StatusInternalServerError
-		if allocation.IsAutocompleteBadRequest(err) {
+		if autocomplete.IsBadRequest(err) {
 			status = http.StatusBadRequest
 		}
 		http.Error(w, fmt.Sprintf("Error getting allocation autocomplete: %s", err), status)
@@ -57,7 +44,7 @@ func (a *Accesses) ComputeAllocationAutocompleteHandler(w http.ResponseWriter, r
 	WriteData(w, resp, nil)
 }
 
-func (a *Accesses) QueryAllocationAutocomplete(req allocation.AllocationAutocompleteRequest, filterString string, ctx context.Context) (*allocation.AllocationAutocompleteResponse, error) {
+func (a *Accesses) QueryAllocationAutocomplete(req autocomplete.Request, filterString string, ctx context.Context) (*autocomplete.Response, error) {
 	asr, err := a.Model.QueryAllocation(req.Window, req.Window.Duration(), nil, false, false, false, false, false, opencost.AccumulateOptionNone, false, filterString)
 	if err != nil {
 		return nil, fmt.Errorf("error querying allocations: %w", err)
@@ -69,38 +56,20 @@ func (a *Accesses) ComputeAssetsAutocompleteHandler(w http.ResponseWriter, r *ht
 	w.Header().Set("Content-Type", "application/json")
 	qp := httputil.NewQueryParams(r.URL.Query())
 
-	window, err := opencost.ParseWindowWithOffset(qp.Get("window", ""), env.GetParsedUTCOffset())
+	offset := env.GetParsedUTCOffset()
+	req, err := coreasset.ParseRequest(qp, autocomplete.ParseOptions{
+		DefaultTenantID: "opencost",
+		UTCOffset:       &offset,
+	})
 	if err != nil {
-		http.Error(w, fmt.Sprintf("Invalid 'window' parameter: %s", err), http.StatusBadRequest)
+		http.Error(w, fmt.Sprintf("Invalid asset autocomplete request: %s", err), http.StatusBadRequest)
 		return
 	}
-	if window.IsOpen() || window.Start() == nil || window.End() == nil {
-		http.Error(w, fmt.Sprintf("Invalid 'window' parameter: %s", window.String()), http.StatusBadRequest)
-		return
-	}
-
-	var parsedFilter filter.Filter
-	filterString := qp.Get("filter", "")
-	if filterString != "" {
-		parser := assetfilter.NewAssetFilterParser()
-		parsedFilter, err = parser.Parse(filterString)
-		if err != nil {
-			http.Error(w, fmt.Sprintf("Invalid 'filter' parameter: %s", err), http.StatusBadRequest)
-			return
-		}
-	}
 
-	resp, err := a.QueryAssetAutocomplete(asset.AssetAutocompleteRequest{
-		TenantID: qp.Get("tenantId", "opencost"),
-		Search:   qp.Get("search", ""),
-		Field:    qp.Get("field", ""),
-		Limit:    qp.GetInt("limit", 0),
-		Window:   window,
-		Filter:   parsedFilter,
-	}, r.Context())
+	resp, err := a.QueryAssetAutocomplete(*req, r.Context())
 	if err != nil {
 		status := http.StatusInternalServerError
-		if asset.IsAutocompleteBadRequest(err) {
+		if autocomplete.IsBadRequest(err) {
 			status = http.StatusBadRequest
 		}
 		http.Error(w, fmt.Sprintf("Error getting asset autocomplete: %s", err), status)
@@ -110,9 +79,9 @@ func (a *Accesses) ComputeAssetsAutocompleteHandler(w http.ResponseWriter, r *ht
 	WriteData(w, resp, nil)
 }
 
-func (a *Accesses) QueryAssetAutocomplete(req asset.AssetAutocompleteRequest, ctx context.Context) (*asset.AssetAutocompleteResponse, error) {
+func (a *Accesses) QueryAssetAutocomplete(req autocomplete.Request, ctx context.Context) (*autocomplete.Response, error) {
 	if req.Window.IsOpen() || req.Window.Start() == nil || req.Window.End() == nil {
-		return nil, fmt.Errorf("%w: invalid window: %s", asset.ErrAutocompleteBadRequest, req.Window.String())
+		return nil, fmt.Errorf("%w: invalid window: %s", autocomplete.ErrBadRequest, req.Window.String())
 	}
 	assetSet, err := a.Model.ComputeAssets(*req.Window.Start(), *req.Window.End())
 	if err != nil {

+ 8 - 8
pkg/costmodel/cluster_helpers_test.go

@@ -1057,10 +1057,10 @@ func TestAssetCustompricing(t *testing.T) {
 				"customPricesEnabled": "true",
 			},
 			expectedPricing: map[string]float64{
-				"CPU":     0.027397,              // 20.0 / 730
-				"RAM":     5.102716386318207e-12, // 4.0 / 730 / 1024^3
-				"GPU":     1.369864,              // 500.0 / 730 * 2
-				"Storage": 0.000137,              // 0.1 / 730 * (1073741824.0 / 1024 / 1024 / 1024) * (60 / 60) => 0.1 / 730 * 1 * 1
+				"CPU":     20.0,
+				"RAM":     4.0 / 1024.0 / 1024.0 / 1024.0,
+				"GPU":     1000.0, // 500.0 per GPU-hour * 2 GPUs
+				"Storage": 0.1,    // 0.1 per GiB-hour * 1 GiB * 1 hour
 			},
 			zeroCollector: false,
 		},
@@ -1075,10 +1075,10 @@ func TestAssetCustompricing(t *testing.T) {
 				// This tests the fallback behavior when collector returns 0
 			},
 			expectedPricing: map[string]float64{
-				"CPU":     0.027397,              // 20.0 / 730 (fallback from 0)
-				"RAM":     5.102716386318207e-12, // 4.0 / 730 / 1024^3 (fallback from 0)
-				"GPU":     0.0,                   // GPU doesn't have fallback logic
-				"Storage": 1.0,                   // Storage uses separate PV pricing (pvCostPromResult), not affected by node pricing
+				"CPU":     20.0,
+				"RAM":     4.0 / 1024.0 / 1024.0 / 1024.0,
+				"GPU":     0.0, // GPU doesn't have fallback logic
+				"Storage": 1.0, // Storage uses separate PV pricing (pvCostPromResult), not affected by node pricing
 			},
 			zeroCollector: true,
 		},

+ 49 - 0
pkg/costmodel/costmodel_test.go

@@ -8,6 +8,7 @@ import (
 
 	"github.com/google/go-cmp/cmp"
 	"github.com/opencost/opencost/core/pkg/clustercache"
+	coreenv "github.com/opencost/opencost/core/pkg/env"
 	"github.com/opencost/opencost/core/pkg/storage"
 	"github.com/opencost/opencost/core/pkg/util"
 	"github.com/opencost/opencost/pkg/cloud/models"
@@ -530,3 +531,51 @@ func TestNodeCostAnnotations(t *testing.T) {
 		})
 	}
 }
+
+func TestCustomProviderGPUNodeUsesDefaultHourlyPricing(t *testing.T) {
+	configPath := t.TempDir()
+	t.Setenv(coreenv.ConfigPathEnvVar, configPath)
+
+	confMan := config.NewConfigFileManager(storage.NewFileStorage("/"))
+	customProvider := &provider.CustomProvider{
+		Config: provider.NewProviderConfig(confMan, "default.json"),
+	}
+	err := customProvider.DownloadPricingData()
+	require.NoError(t, err)
+
+	cfg, err := customProvider.GetConfig()
+	require.NoError(t, err)
+
+	costModel := &CostModel{
+		Provider: customProvider,
+		Cache: &clustercache.MockClusterCache{
+			Nodes: []*clustercache.Node{
+				{
+					Name: "on-prem-gpu-node",
+					Labels: map[string]string{
+						"kubernetes.io/arch": "amd64",
+					},
+					Status: v1.NodeStatus{
+						Capacity: v1.ResourceList{
+							v1.ResourceCPU:    resource.MustParse("16"),
+							v1.ResourceMemory: resource.MustParse("128Gi"),
+							"nvidia.com/gpu":  resource.MustParse("2"),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	nodeCost, err := costModel.GetNodeCost()
+	require.NoError(t, err)
+
+	node, ok := nodeCost["on-prem-gpu-node"]
+	require.True(t, ok)
+
+	assert.Equal(t, cfg.CPU, node.VCPUCost)
+	assert.Equal(t, cfg.RAM, node.RAMCost)
+	assert.Equal(t, cfg.GPU, node.GPUCost)
+	assert.Equal(t, "2.000000", node.GPU)
+	assert.Empty(t, node.ProviderID)
+}

+ 5 - 4
pkg/mcp/server_test.go

@@ -6,6 +6,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/opencost/opencost/core/pkg/autocomplete"
 	"github.com/opencost/opencost/core/pkg/clustercache"
 	"github.com/opencost/opencost/core/pkg/opencost"
 	models "github.com/opencost/opencost/pkg/cloud/models"
@@ -544,8 +545,8 @@ func (dq *dummyQuerier) Query(_ context.Context, req cloudcost.QueryRequest) (*o
 	return ccsr, nil
 }
 
-func (dq *dummyQuerier) QueryCloudCostAutocomplete(_ context.Context, _ cloudcost.CloudCostAutocompleteRequest) (*cloudcost.CloudCostAutocompleteResponse, error) {
-	return &cloudcost.CloudCostAutocompleteResponse{Data: []string{}}, nil
+func (dq *dummyQuerier) QueryCloudCostAutocomplete(_ context.Context, _ autocomplete.Request) (*autocomplete.Response, error) {
+	return &autocomplete.Response{Data: []string{}}, nil
 }
 
 func TestBuildCloudCostQueryRequest_AccumulateParsing(t *testing.T) {
@@ -1485,13 +1486,13 @@ func (caq *contextAwareQuerier) Query(ctx context.Context, req cloudcost.QueryRe
 	}
 }
 
-func (caq *contextAwareQuerier) QueryCloudCostAutocomplete(ctx context.Context, _ cloudcost.CloudCostAutocompleteRequest) (*cloudcost.CloudCostAutocompleteResponse, error) {
+func (caq *contextAwareQuerier) QueryCloudCostAutocomplete(ctx context.Context, _ autocomplete.Request) (*autocomplete.Response, error) {
 	select {
 	case <-ctx.Done():
 		caq.contextWasCancelled = true
 		return nil, ctx.Err()
 	default:
-		return &cloudcost.CloudCostAutocompleteResponse{Data: []string{}}, nil
+		return &autocomplete.Response{Data: []string{}}, nil
 	}
 }