Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions api/spec/packages/aip/src/llmcost/operations.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,32 @@ namespace LLMCost;
* TODO: This is a temporary solution to support the filter API.
*/
@friendlyName("FilterSingleString")
// @useRef("../../../../common/definitions/aip_filters.yaml#/components/schemas/StringFieldFilter")
model FilterSingleString {
/**
* The field must match the provided value.
*/
@extension("x-omitempty", true)
eq?: string;

/**
* The field must match the provided value. This is an "override" filter that can be used when the default `eq` filter is not sufficient, e.g. when filtering by `model_name` where the name can have multiple variants for the same model ID.
*/
oeq?: string;

/**
* The field must not match the provided value.
*/
@extension("x-omitempty", true)
neq?: string;

/**
* The field must contain the provided value.
*/
@extension("x-omitempty", true)
contains?: string;

/**
* The field must contain the provided value. This is an "override" filter that can be used when the default `contains` filter is not sufficient.
*/
ocontains?: string;
}

/**
Expand All @@ -44,16 +52,20 @@ model FilterSingleString {
@friendlyName("ListLLMCostPricesParamsFilter")
model ListPricesParamsFilter {
/** Filter by provider. e.g. ?filter[provider][eq]=openai */
provider?: FilterSingleString;
@extension("x-go-type", "FilterString")
provider?: string | FilterSingleString;

/** Filter by model ID. e.g. ?filter[model_id][eq]=gpt-4 */
model_id?: FilterSingleString;
@extension("x-go-type", "FilterString")
model_id?: string | FilterSingleString;

/** Filter by model name. e.g. ?filter[model_name][contains]=gpt */
model_name?: FilterSingleString;
@extension("x-go-type", "FilterString")
model_name?: string | FilterSingleString;

/** Filter by currency code. e.g. ?filter[currency][eq]=USD */
currency?: FilterSingleString;
@extension("x-go-type", "FilterString")
currency?: string | FilterSingleString;
Comment on lines 54 to +68
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

The new shorthand form should be documented too.

These filters now accept string | FilterSingleString, but the examples still only show filter[field][eq]=.... Since bare filter[field]=value is supported as the eq shorthand, the spec is under-documenting the public syntax.

As per coding guidelines "The declared API should be accurate, in parity with the actual implementation, and easy to understand for the user."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/spec/packages/aip/src/llmcost/operations.tsp` around lines 54 - 68,
Update the docs for the filter fields (provider, model_id, model_name, currency)
to show the supported shorthand syntax in addition to the explicit operator
form: explain that these fields accept string | FilterSingleString and that a
bare query like filter[provider]=openai (or filter[model_id]=gpt-4,
filter[model_name]=gpt, filter[currency]=USD) is accepted as the eq shorthand
equivalent to filter[provider][eq]=..., so add examples and a short note about
the shorthand next to each field declaration (references: provider, model_id,
model_name, currency, FilterSingleString).

}

/**
Expand Down
692 changes: 350 additions & 342 deletions api/v3/api.gen.go

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions api/v3/api.go
Original file line number Diff line number Diff line change
@@ -1,2 +1,35 @@
//go:generate go tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=codegen.yaml ./openapi.yaml
package v3

import (
"net/http"
)

// FilterString A filter for a string field.
type FilterString struct {
// Contains The field must contain the provided value.
Contains *string `json:"contains,omitempty"`

// Eq The field must match the provided value.
Eq *string `json:"eq,omitempty"`

// Neq The field must not match the provided value.
Neq *string `json:"neq,omitempty"`

// Ocontains asd
Ocontains *string `json:"ocontains,omitempty"`

// Oeq aasd
Oeq *string `json:"oeq,omitempty"`
}

func (f *FilterString) ParseEq(name string, r *http.Request) {
if f == nil {
f = &FilterString{}
}
query := r.URL.Query()
eq := query.Get(name)
if eq != "" {
f.Eq = &eq
}
}
Comment on lines +26 to +35
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the helper and the current call sites.
sed -n '20,40p' api/v3/api.go
rg -n --type=go -C2 '\.ParseEq\(' api/v3

Repository: openmeterio/openmeter

Length of output: 1989


🏁 Script executed:

# Find the Filter type definition
fd -t f "\.go$" api/v3 -x grep -l "type.*Filter" {} \;

# Look for the structure that contains Provider, ModelId, etc.
rg -A 20 "type.*Filter.*struct" api/v3 --type=go | head -100

# Check how params.Filter is initialized
rg -B 5 -A 5 "params.Filter\s*=" api/v3/handlers/llmcost --type=go

Repository: openmeterio/openmeter

Length of output: 5053


🏁 Script executed:

# Find the Filter type that contains Provider, ModelId, ModelName, Currency
rg -B 5 "Provider.*Filter" api/v3/handlers/llmcost/list_overrides.go --type=go | head -30

# Search for the struct definition with these fields
rg "type.*struct" api/v3 --type=go -A 10 | grep -A 15 "Provider"

Repository: openmeterio/openmeter

Length of output: 1771


🏁 Script executed:

# Find the handler function signature in list_overrides.go
rg -B 20 "params.Filter.Provider.ParseEq" api/v3/handlers/llmcost/list_overrides.go --type=go | head -50

# Look for the type definition of the params variable
rg "type.*Override.*struct|type.*ListOverrides.*struct" api/v3 --type=go -A 20

Repository: openmeterio/openmeter

Length of output: 2697


🏁 Script executed:

# Find the ListLLMCostPricesParamsFilter struct definition
rg "type ListLLMCostPricesParamsFilter struct" api/v3 --type=go -A 20

Repository: openmeterio/openmeter

Length of output: 1320


ParseEq doesn't update the caller's field when it starts nil.

These filter fields (Provider, ModelId, etc.) are pointers to FilterString, so they can be nil. When you call f.ParseEq(...) on a nil pointer, the method rebinds the local f variable but doesn't update the original field. The freshly allocated FilterString gets discarded, and any parsed value is lost. Flat filter[provider]={value} params get dropped unless the field was already initialized elsewhere.

Consider initializing these filter fields upfront, returning the initialized struct from ParseEq, or using a helper that directly updates the field.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/v3/api.go` around lines 26 - 35, The ParseEq method on FilterString
currently allocates a new FilterString when the receiver is nil but only
reassigns the local receiver, so callers like the pointer fields
Provider/ModelId never get updated; change the API so the initializer is
propagated back to the caller—for example modify FilterString.ParseEq to return
the (possibly newly allocated) *FilterString and update call sites (e.g.,
Provider = Provider.ParseEq(...), ModelId = ModelId.ParseEq(...)), or
alternatively provide a helper function that accepts a **FilterString and
initializes/sets it in-place; update all usages to use the returned value or the
helper so parsed values are not lost.

34 changes: 31 additions & 3 deletions api/v3/filters/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,50 @@ package filters
import "errors"

// StringFilter represents a filter operation on a string field.
// Exactly one of Eq, Neq, or Contains should be set.
type StringFilter struct {
// Eq requires the field to match the provided value exactly (case-insensitive).
Eq *string `json:"eq,omitempty"`

// Neq requires the field to not match the provided value (case-insensitive).
Neq *string `json:"neq,omitempty"`

// Gt requires the field to be greater than the provided value.
Gt *string `json:"gt,omitempty"`

// Gte requires the field to be greater than or equal to the provided value.
Gte *string `json:"gte,omitempty"`

// Lt requires the field to be less than the provided value.
Lt *string `json:"lt,omitempty"`

// Lte requires the field to be less than or equal to the provided value.
Lte *string `json:"lte,omitempty"`

// Contains requires the field to contain the provided value (case-insensitive).
Contains *string `json:"contains,omitempty"`

// Oeq requires the field to match any of the provided comma-separated values (case-insensitive).
Oeq *string `json:"oeq,omitempty"`

// Ocontains requires the field to contain any of the provided comma-separated values (case-insensitive).
Ocontains *string `json:"ocontains,omitempty"`

// Exists requires the field to be present (true) or absent (false).
Exists *bool `json:"exists,omitempty"`
}

// IsEmpty returns true if no filter operator is set.
func (f StringFilter) IsEmpty() bool {
return f.Eq == nil && f.Neq == nil && f.Contains == nil
return f.Eq == nil &&
f.Neq == nil &&
f.Gt == nil &&
f.Gte == nil &&
f.Lt == nil &&
f.Lte == nil &&
f.Contains == nil &&
f.Oeq == nil &&
f.Ocontains == nil &&
f.Exists == nil
}

// Validate validates the filter.
Expand All @@ -26,7 +55,6 @@ func (f StringFilter) Validate() error {
return nil
}

// Check for mutually exclusive filters
if f.Eq != nil && f.Neq != nil {
return errors.New("eq and neq cannot be set at the same time")
}
Expand Down
2 changes: 1 addition & 1 deletion api/v3/handlers/llmcost/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ func validPriceSortField(field string) bool {

// filterSingleStringToDomain converts an API FilterSingleString to the domain StringFilter.
// Returns nil if the input is nil or empty.
func filterSingleStringToDomain(f *api.FilterSingleString) (*filters.StringFilter, error) {
func filterSingleStringToDomain(f *api.FilterString) (*filters.StringFilter, error) {
if f == nil {
return nil, nil
}
Expand Down
12 changes: 12 additions & 0 deletions api/v3/handlers/llmcost/list_overrides.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package llmcost

import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"

"github.com/samber/lo"
Expand Down Expand Up @@ -53,31 +55,41 @@ func (h *handler) ListOverrides() ListOverridesHandler {

// Filters
if params.Filter != nil {
params.Filter.Provider.ParseEq("filter[provider]", r)
provider, err := filterSingleStringToDomain(params.Filter.Provider)
if err != nil {
return req, err
}
req.Provider = provider

params.Filter.ModelId.ParseEq("filter[model_id]", r)
modelID, err := filterSingleStringToDomain(params.Filter.ModelId)
if err != nil {
return req, err
}
req.ModelID = modelID

params.Filter.ModelName.ParseEq("filter[model_name]", r)
modelName, err := filterSingleStringToDomain(params.Filter.ModelName)
if err != nil {
return req, err
}
req.ModelName = modelName

params.Filter.Currency.ParseEq("filter[currency]", r)
currency, err := filterSingleStringToDomain(params.Filter.Currency)
if err != nil {
return req, err
}
req.Currency = currency
}

j, err := json.Marshal(req)
if err != nil {
return req, err
}
slog.Info("req", "req", string(j))

return req, nil
},
func(ctx context.Context, request ListOverridesRequest) (ListOverridesResponse, error) {
Expand Down
117 changes: 58 additions & 59 deletions api/v3/handlers/llmcost/list_prices.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,78 +24,77 @@ type (
ListPricesHandler = httptransport.HandlerWithArgs[ListPricesRequest, ListPricesResponse, ListPricesParams]
)

var listPricesAuthorizedFilters = map[string]request.AIPFilterOption{
"provider": {
Filters: []request.QueryFilterOp{
request.QueryFilterEQ,
request.QueryFilterNEQ,
request.QueryFilterContains,
request.QueryFilterOrContains,
},
},
"model_id": {
Filters: []request.QueryFilterOp{
request.QueryFilterEQ,
request.QueryFilterNEQ,
request.QueryFilterContains,
},
},
"model_name": {
Filters: []request.QueryFilterOp{
request.QueryFilterEQ,
request.QueryFilterNEQ,
request.QueryFilterContains,
},
},
"currency": {
Filters: []request.QueryFilterOp{
request.QueryFilterEQ,
request.QueryFilterNEQ,
request.QueryFilterContains,
},
},
}

var listPricesAuthorizedSorts = []string{
"id", "provider.id", "model.id", "effective_from", "effective_to",
}

func (h *handler) ListPrices() ListPricesHandler {
return httptransport.NewHandlerWithArgs(
func(ctx context.Context, r *http.Request, params ListPricesParams) (ListPricesRequest, error) {
func(ctx context.Context, r *http.Request, _ ListPricesParams) (ListPricesRequest, error) {
ns, err := h.resolveNamespace(ctx)
if err != nil {
return ListPricesRequest{}, err
}

req := ListPricesRequest{
Namespace: ns,
attrs, err := request.GetAipAttributes(r,
request.WithDefaultPageSize(20),
request.WithMaxPageSize(100),
request.WithAuthorizedSorts(listPricesAuthorizedSorts),
request.WithAuthorizedFilters(listPricesAuthorizedFilters),
)
if err != nil {
return ListPricesRequest{}, err
}

// Pagination
req.Page = pagination.NewPage(1, 20)

if params.Page != nil {
req.Page = pagination.NewPage(
lo.FromPtrOr(params.Page.Number, 1),
lo.FromPtrOr(params.Page.Size, 20),
)

if err := req.Page.Validate(); err != nil {
return req, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
{Field: "page", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
})
}
pageNumber := attrs.Pagination.Number
if pageNumber < 1 {
pageNumber = 1
}

// Sort
if params.Sort != nil {
sort, err := request.ParseSortBy(*params.Sort)
if err != nil {
return req, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
{Field: "sort", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
})
}

if !validPriceSortField(sort.Field) {
return req, apierrors.NewBadRequestError(ctx, fmt.Errorf("unsupported sort field: %s", sort.Field), apierrors.InvalidParameters{
{Field: "sort", Reason: fmt.Sprintf("unsupported sort field %q, must be one of: id, provider.id, model.id, effective_from, effective_to", sort.Field), Source: apierrors.InvalidParamSourceQuery},
})
}

req.OrderBy = sort.Field
req.Order = sort.Order.ToSortxOrder()
req := ListPricesRequest{
Namespace: ns,
Page: pagination.NewPage(pageNumber, attrs.Pagination.Size),
Provider: request.FilterStringFromAip(attrs.Filters, "provider"),
ModelID: request.FilterStringFromAip(attrs.Filters, "model_id"),
ModelName: request.FilterStringFromAip(attrs.Filters, "model_name"),
Currency: request.FilterStringFromAip(attrs.Filters, "currency"),
}

// Filters
if params.Filter != nil {
provider, err := filterSingleStringToDomain(params.Filter.Provider)
if err != nil {
return req, err
}
req.Provider = provider

modelID, err := filterSingleStringToDomain(params.Filter.ModelId)
if err != nil {
return req, err
}
req.ModelID = modelID

modelName, err := filterSingleStringToDomain(params.Filter.ModelName)
if err != nil {
return req, err
}
req.ModelName = modelName

currency, err := filterSingleStringToDomain(params.Filter.Currency)
if err != nil {
return req, err
}
req.Currency = currency
if len(attrs.Sorts) > 0 {
req.OrderBy = attrs.Sorts[0].Field
req.Order = attrs.Sorts[0].Order.ToSortxOrder()
}

return req, nil
Expand Down
25 changes: 0 additions & 25 deletions api/v3/oasmiddleware/decoder.go

This file was deleted.

Loading
Loading