Alex Meijer 6 дней назад
Родитель
Сommit
e8b35970f8
41 измененных файлов с 1472 добавлено и 391 удалено
  1. 98 0
      core/pkg/autocomplete/allocation/allocation_test.go
  2. 13 0
      core/pkg/autocomplete/allocation/parse.go
  3. 35 0
      core/pkg/autocomplete/allocation/route.go
  4. 11 0
      core/pkg/autocomplete/allocation/types.go
  5. 32 0
      core/pkg/autocomplete/allocation/validate.go
  6. 130 0
      core/pkg/autocomplete/asset/asset_test.go
  7. 16 0
      core/pkg/autocomplete/asset/parse.go
  8. 33 0
      core/pkg/autocomplete/asset/route.go
  9. 34 0
      core/pkg/autocomplete/asset/static.go
  10. 11 0
      core/pkg/autocomplete/asset/types.go
  11. 39 0
      core/pkg/autocomplete/asset/validate.go
  12. 79 0
      core/pkg/autocomplete/autocomplete_test.go
  13. 82 0
      core/pkg/autocomplete/cloudcost/cloudcost_test.go
  14. 13 0
      core/pkg/autocomplete/cloudcost/parse.go
  15. 11 0
      core/pkg/autocomplete/cloudcost/types.go
  16. 30 0
      core/pkg/autocomplete/cloudcost/validate.go
  17. 11 0
      core/pkg/autocomplete/errors.go
  18. 49 0
      core/pkg/autocomplete/label.go
  19. 37 0
      core/pkg/autocomplete/label_test.go
  20. 4 0
      core/pkg/autocomplete/limits.go
  21. 64 0
      core/pkg/autocomplete/normalize.go
  22. 86 0
      core/pkg/autocomplete/parse.go
  23. 22 0
      core/pkg/autocomplete/request.go
  24. 158 0
      core/pkg/autocomplete/request_test.go
  25. 76 0
      core/pkg/autocomplete/util.go
  26. 41 0
      pkg/allocation/autocomplete_parser_test.go
  27. 13 94
      pkg/allocation/autocompletequeryservice.go
  28. 23 16
      pkg/allocation/autocompletequeryservice_test.go
  29. 44 0
      pkg/asset/autocomplete_normalize_test.go
  30. 75 0
      pkg/asset/autocomplete_parser_test.go
  31. 14 0
      pkg/asset/autocomplete_static_test.go
  32. 24 94
      pkg/asset/autocompletequeryservice.go
  33. 9 8
      pkg/asset/autocompletequeryservice_test.go
  34. 9 8
      pkg/cloudcost/autocomplete_test.go
  35. 2 16
      pkg/cloudcost/querier.go
  36. 4 2
      pkg/cloudcost/queryservice.go
  37. 0 37
      pkg/cloudcost/queryservice_helper.go
  38. 6 4
      pkg/cloudcost/queryservice_helper_test.go
  39. 7 55
      pkg/cloudcost/repositoryquerier.go
  40. 22 53
      pkg/costmodel/autocomplete.go
  41. 5 4
      pkg/mcp/server_test.go

+ 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
 package allocation
 
 
 import (
 import (
-	"context"
-	"errors"
 	"fmt"
 	"fmt"
-	"sort"
 	"strings"
 	"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"
 	"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 {
 	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
 	var matcher opencost.AllocationMatcher
@@ -58,7 +22,7 @@ func QueryAllocationAutocompleteFromSetRange(asr *opencost.AllocationSetRange, r
 		compiler := opencost.NewAllocationMatchCompiler(req.LabelConfig)
 		compiler := opencost.NewAllocationMatchCompiler(req.LabelConfig)
 		matcher, err = compiler.Compile(req.Filter)
 		matcher, err = compiler.Compile(req.Filter)
 		if err != nil {
 		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 {
 func allocationAutocompleteValues(props *opencost.AllocationProperties, field string) []string {
 	switch {
 	switch {
+	case field == "account":
+		return nil
 	case field == "cluster":
 	case field == "cluster":
 		return []string{props.Cluster}
 		return []string{props.Cluster}
 	case field == "namespace":
 	case field == "namespace":
@@ -135,14 +78,14 @@ func allocationAutocompleteValues(props *opencost.AllocationProperties, field st
 		return mapKeys(props.Labels)
 		return mapKeys(props.Labels)
 	case strings.HasPrefix(field, "label:"):
 	case strings.HasPrefix(field, "label:"):
 		label := strings.TrimPrefix(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}
 			return []string{v}
 		}
 		}
 	case field == "namespacelabel":
 	case field == "namespacelabel":
 		return mapKeys(props.NamespaceLabels)
 		return mapKeys(props.NamespaceLabels)
 	case strings.HasPrefix(field, "namespacelabel:"):
 	case strings.HasPrefix(field, "namespacelabel:"):
 		label := strings.TrimPrefix(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}
 			return []string{v}
 		}
 		}
 	}
 	}
@@ -156,27 +99,3 @@ func mapKeys(values map[string]string) []string {
 	}
 	}
 	return result
 	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"
 	"testing"
 	"time"
 	"time"
 
 
+	"github.com/opencost/opencost/core/pkg/autocomplete"
 	"github.com/opencost/opencost/core/pkg/opencost"
 	"github.com/opencost/opencost/core/pkg/opencost"
 )
 )
 
 
@@ -34,10 +35,12 @@ func TestQueryAllocationAutocompleteFromSetRange(t *testing.T) {
 	}))
 	}))
 
 
 	asr := opencost.NewAllocationSetRange(as)
 	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 {
 	if err != nil {
 		t.Fatalf("unexpected error: %v", err)
 		t.Fatalf("unexpected error: %v", err)
@@ -46,9 +49,10 @@ func TestQueryAllocationAutocompleteFromSetRange(t *testing.T) {
 		t.Fatalf("unexpected label autocomplete response: %+v", resp.Data)
 		t.Fatalf("unexpected label autocomplete response: %+v", resp.Data)
 	}
 	}
 
 
-	valueResp, err := QueryAllocationAutocompleteFromSetRange(asr, AllocationAutocompleteRequest{
+	valueResp, err := QueryAllocationAutocompleteFromSetRange(asr, autocomplete.Request{
 		Field:  "label:team",
 		Field:  "label:team",
 		Search: "plat",
 		Search: "plat",
+		Window: window,
 	})
 	})
 	if err != nil {
 	if err != nil {
 		t.Fatalf("unexpected error: %v", err)
 		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)
 		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 {
 	if err != nil {
 		t.Fatalf("unexpected error: %v", err)
 		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)
 		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 {
 	if err == nil {
 		t.Fatal("expected error for excessive limit")
 		t.Fatal("expected error for excessive limit")
 	}
 	}
-	if !IsAutocompleteBadRequest(err) {
+	if !autocomplete.IsBadRequest(err) {
 		t.Fatalf("expected bad request error, got: %v", 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
 package asset
 
 
 import (
 import (
-	"context"
-	"errors"
 	"fmt"
 	"fmt"
-	"sort"
 	"strings"
 	"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"
 	"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
 		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 {
 	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
 	var matcher opencost.AssetMatcher
@@ -65,7 +41,7 @@ func QueryAssetAutocompleteFromSet(assetSet *opencost.AssetSet, req AssetAutocom
 		compiler := opencost.NewAssetMatchCompiler()
 		compiler := opencost.NewAssetMatchCompiler()
 		matcher, err = compiler.Compile(req.Filter)
 		matcher, err = compiler.Compile(req.Filter)
 		if err != nil {
 		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 {
 func assetAutocompleteValues(asset opencost.Asset, field string) []string {
@@ -156,21 +98,9 @@ func assetAutocompleteValues(asset opencost.Asset, field string) []string {
 		return keys
 		return keys
 	case strings.HasPrefix(field, "label:"):
 	case strings.HasPrefix(field, "label:"):
 		labelName := strings.TrimPrefix(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 []string{value}
 		}
 		}
 	}
 	}
 	return nil
 	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"
 	"testing"
 	"time"
 	"time"
 
 
+	"github.com/opencost/opencost/core/pkg/autocomplete"
 	"github.com/opencost/opencost/core/pkg/opencost"
 	"github.com/opencost/opencost/core/pkg/opencost"
 )
 )
 
 
@@ -24,7 +25,7 @@ func TestQueryAssetAutocompleteFromSet(t *testing.T) {
 
 
 	assetSet := opencost.NewAssetSet(start, end, nodeA, nodeB)
 	assetSet := opencost.NewAssetSet(start, end, nodeA, nodeB)
 
 
-	resp, err := QueryAssetAutocompleteFromSet(assetSet, AssetAutocompleteRequest{
+	resp, err := QueryAssetAutocompleteFromSet(assetSet, autocomplete.Request{
 		TenantID: "opencost",
 		TenantID: "opencost",
 		Field:    "cluster",
 		Field:    "cluster",
 		Window:   window,
 		Window:   window,
@@ -36,7 +37,7 @@ func TestQueryAssetAutocompleteFromSet(t *testing.T) {
 		t.Fatalf("unexpected cluster autocomplete response: %+v", resp.Data)
 		t.Fatalf("unexpected cluster autocomplete response: %+v", resp.Data)
 	}
 	}
 
 
-	labelResp, err := QueryAssetAutocompleteFromSet(assetSet, AssetAutocompleteRequest{
+	labelResp, err := QueryAssetAutocompleteFromSet(assetSet, autocomplete.Request{
 		TenantID: "opencost",
 		TenantID: "opencost",
 		Field:    "label:team",
 		Field:    "label:team",
 		Search:   "plat",
 		Search:   "plat",
@@ -49,17 +50,17 @@ func TestQueryAssetAutocompleteFromSet(t *testing.T) {
 		t.Fatalf("unexpected label autocomplete response: %+v", labelResp.Data)
 		t.Fatalf("unexpected label autocomplete response: %+v", labelResp.Data)
 	}
 	}
 
 
-	_, err = QueryAssetAutocompleteFromSet(assetSet, AssetAutocompleteRequest{
+	_, err = QueryAssetAutocompleteFromSet(assetSet, autocomplete.Request{
 		Field: "cluster",
 		Field: "cluster",
 	})
 	})
 	if err == nil {
 	if err == nil {
 		t.Fatal("expected error when tenant ID is missing")
 		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)
 		t.Fatalf("expected bad request error, got: %v", err)
 	}
 	}
 
 
-	_, err = QueryAssetAutocompleteFromSet(assetSet, AssetAutocompleteRequest{
+	_, err = QueryAssetAutocompleteFromSet(assetSet, autocomplete.Request{
 		TenantID: "opencost",
 		TenantID: "opencost",
 		Field:    "labels",
 		Field:    "labels",
 		Window:   window,
 		Window:   window,
@@ -67,12 +68,12 @@ func TestQueryAssetAutocompleteFromSet(t *testing.T) {
 	if err == nil {
 	if err == nil {
 		t.Fatal("expected error for invalid field prefix")
 		t.Fatal("expected error for invalid field prefix")
 	}
 	}
-	if !IsAutocompleteBadRequest(err) {
+	if !autocomplete.IsBadRequest(err) {
 		t.Fatalf("expected bad request error, got: %v", err)
 		t.Fatalf("expected bad request error, got: %v", err)
 	}
 	}
 
 
 	openWindow := opencost.NewWindow(&start, nil)
 	openWindow := opencost.NewWindow(&start, nil)
-	_, err = QueryAssetAutocompleteFromSet(assetSet, AssetAutocompleteRequest{
+	_, err = QueryAssetAutocompleteFromSet(assetSet, autocomplete.Request{
 		TenantID: "opencost",
 		TenantID: "opencost",
 		Field:    "name",
 		Field:    "name",
 		Window:   openWindow,
 		Window:   openWindow,
@@ -80,7 +81,7 @@ func TestQueryAssetAutocompleteFromSet(t *testing.T) {
 	if err == nil {
 	if err == nil {
 		t.Fatal("expected error for open window")
 		t.Fatal("expected error for open window")
 	}
 	}
-	if !IsAutocompleteBadRequest(err) {
+	if !autocomplete.IsBadRequest(err) {
 		t.Fatalf("expected bad request error, got: %v", err)
 		t.Fatalf("expected bad request error, got: %v", err)
 	}
 	}
 }
 }

+ 9 - 8
pkg/cloudcost/autocomplete_test.go

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

+ 2 - 16
pkg/cloudcost/querier.go

@@ -6,6 +6,7 @@ import (
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
+	"github.com/opencost/opencost/core/pkg/autocomplete"
 	"github.com/opencost/opencost/core/pkg/filter"
 	"github.com/opencost/opencost/core/pkg/filter"
 	"github.com/opencost/opencost/core/pkg/opencost"
 	"github.com/opencost/opencost/core/pkg/opencost"
 )
 )
@@ -13,7 +14,7 @@ import (
 // Querier allows for querying ranges of CloudCost data
 // Querier allows for querying ranges of CloudCost data
 type Querier interface {
 type Querier interface {
 	Query(context.Context, QueryRequest) (*opencost.CloudCostSetRange, error)
 	Query(context.Context, QueryRequest) (*opencost.CloudCostSetRange, error)
-	QueryCloudCostAutocomplete(context.Context, CloudCostAutocompleteRequest) (*CloudCostAutocompleteResponse, error)
+	QueryCloudCostAutocomplete(context.Context, autocomplete.Request) (*autocomplete.Response, error)
 }
 }
 
 
 type QueryRequest struct {
 type QueryRequest struct {
@@ -24,21 +25,6 @@ type QueryRequest struct {
 	Filter      filter.Filter
 	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
 // DefaultChartItemsLength the default max number of items for a ViewGraphDataSet
 const DefaultChartItemsLength int = 10
 const DefaultChartItemsLength int = 10
 
 

+ 4 - 2
pkg/cloudcost/queryservice.go

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

+ 0 - 37
pkg/cloudcost/queryservice_helper.go

@@ -64,43 +64,6 @@ func ParseCloudCostRequest(qp httputil.QueryParams) (*QueryRequest, error) {
 	return opts, nil
 	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) {
 func ParseCloudCostViewRequest(qp httputil.QueryParams) (*ViewQueryRequest, error) {
 	qr, err := ParseCloudCostRequest(qp)
 	qr, err := ParseCloudCostRequest(qp)
 	if err != nil {
 	if err != nil {

+ 6 - 4
pkg/cloudcost/queryservice_helper_test.go

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

+ 7 - 55
pkg/cloudcost/repositoryquerier.go

@@ -2,23 +2,16 @@ package cloudcost
 
 
 import (
 import (
 	"context"
 	"context"
-	"errors"
 	"fmt"
 	"fmt"
 	"sort"
 	"sort"
 	"strings"
 	"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/log"
 	"github.com/opencost/opencost/core/pkg/opencost"
 	"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
 // RepositoryQuerier is an implementation of Querier and ViewQuerier which pulls directly from a Repository
 type RepositoryQuerier struct {
 type RepositoryQuerier struct {
 	repo Repository
 	repo Repository
@@ -77,23 +70,12 @@ func (rq *RepositoryQuerier) Query(ctx context.Context, request QueryRequest) (*
 	return ccsr, nil
 	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 {
 	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{
 	ccsr, err := rq.Query(ctx, QueryRequest{
 		Start:      *request.Window.Start(),
 		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 {
 func cloudCostAutocompleteValues(cc *opencost.CloudCost, field string) []string {

+ 22 - 53
pkg/costmodel/autocomplete.go

@@ -6,9 +6,9 @@ import (
 	"net/http"
 	"net/http"
 
 
 	"github.com/julienschmidt/httprouter"
 	"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/opencost"
 	"github.com/opencost/opencost/core/pkg/util/httputil"
 	"github.com/opencost/opencost/core/pkg/util/httputil"
 	"github.com/opencost/opencost/pkg/allocation"
 	"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")
 	w.Header().Set("Content-Type", "application/json")
 	qp := httputil.NewQueryParams(r.URL.Query())
 	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 {
 	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
 		return
 	}
 	}
 
 
-	var parsedFilter filter.Filter
 	filterString := qp.Get("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 {
 	if err != nil {
 		status := http.StatusInternalServerError
 		status := http.StatusInternalServerError
-		if allocation.IsAutocompleteBadRequest(err) {
+		if autocomplete.IsBadRequest(err) {
 			status = http.StatusBadRequest
 			status = http.StatusBadRequest
 		}
 		}
 		http.Error(w, fmt.Sprintf("Error getting allocation autocomplete: %s", err), status)
 		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)
 	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)
 	asr, err := a.Model.QueryAllocation(req.Window, req.Window.Duration(), nil, false, false, false, false, false, opencost.AccumulateOptionNone, false, filterString)
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf("error querying allocations: %w", err)
 		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")
 	w.Header().Set("Content-Type", "application/json")
 	qp := httputil.NewQueryParams(r.URL.Query())
 	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 {
 	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
 		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 {
 	if err != nil {
 		status := http.StatusInternalServerError
 		status := http.StatusInternalServerError
-		if asset.IsAutocompleteBadRequest(err) {
+		if autocomplete.IsBadRequest(err) {
 			status = http.StatusBadRequest
 			status = http.StatusBadRequest
 		}
 		}
 		http.Error(w, fmt.Sprintf("Error getting asset autocomplete: %s", err), status)
 		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)
 	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 {
 	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())
 	assetSet, err := a.Model.ComputeAssets(*req.Window.Start(), *req.Window.End())
 	if err != nil {
 	if err != nil {

+ 5 - 4
pkg/mcp/server_test.go

@@ -6,6 +6,7 @@ import (
 	"testing"
 	"testing"
 	"time"
 	"time"
 
 
+	"github.com/opencost/opencost/core/pkg/autocomplete"
 	"github.com/opencost/opencost/core/pkg/clustercache"
 	"github.com/opencost/opencost/core/pkg/clustercache"
 	"github.com/opencost/opencost/core/pkg/opencost"
 	"github.com/opencost/opencost/core/pkg/opencost"
 	models "github.com/opencost/opencost/pkg/cloud/models"
 	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
 	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) {
 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 {
 	select {
 	case <-ctx.Done():
 	case <-ctx.Done():
 		caq.contextWasCancelled = true
 		caq.contextWasCancelled = true
 		return nil, ctx.Err()
 		return nil, ctx.Err()
 	default:
 	default:
-		return &cloudcost.CloudCostAutocompleteResponse{Data: []string{}}, nil
+		return &autocomplete.Response{Data: []string{}}, nil
 	}
 	}
 }
 }