Skip to content

feat(api): v3 feature handlers and unit config#3986

Open
hekike wants to merge 14 commits intomainfrom
feat/feature-meter-id
Open

feat(api): v3 feature handlers and unit config#3986
hekike wants to merge 14 commits intomainfrom
feat/feature-meter-id

Conversation

@hekike
Copy link
Contributor

@hekike hekike commented Mar 20, 2026

Feature Meter ID

Summary

Migrate features from referencing meters by slug to referencing by ID. Adds v3 feature CRUD handlers (/api/v3/openmeter/features) that use meter ID natively, while preserving full v1 API backward compatibility.

What

  • V3 Feature API: New CRUD handlers for features under /api/v3/openmeter/features that reference meters by ID instead of slug, with structured filter syntax (eq, in, neq, etc.).
  • Ent schema migration: Add meter_id column to the features table with a foreign key to meters, add reverse edge from meters to features.
  • Meter ID normalization: The feature connector resolves meter references to canonical IDs on create, regardless of whether the caller passes a slug (v1) or ID (v3).
  • V1 backward compat: meterSlug continues to be populated in v1 responses by eager-loading the meter edge on read, and by populating from the resolved meter on create.
  • Unit cost support: Both v1 and v3 support manual and llm unit cost types, with LLM pricing resolution from the cost database.
  • Filter validation: V3 create validates meter group-by filters against the meter's declared dimensions.

Why

Meter slugs can change; referencing by immutable ID is more robust. The v3 API provides a cleaner contract (structured filters, labels instead of metadata, snake_case fields) while the v1 API remains unchanged.

CodeRabbit Review Fixes

Addressed valid review comments:

  1. Fix ForbiddenError HTTP status (error_encoder.go): Changed http.StatusBadRequest to http.StatusForbidden (403) for ForbiddenError.
  2. Fix filter round-trip data loss (convert.go): convertFilterStringToAPIMapItem was missing Contains, Ncontains, And, Or fields. Added reverse conversion including reverseContainsPattern helper and recursive And/Or conversion.
  3. Add FeatureConnector nil check (server.go): Added validation in Config.Validate() alongside all other required service checks to prevent runtime panics.
  4. Add empty meter ID validation (create.go): Validate body.Meter.Id is non-empty before calling GetMeterByIDOrSlug (defense-in-depth; OpenAPI schema also validates ULID format).
  5. Use ULID-shaped meter ID in test (convert_test.go): Replaced "tokens_total" with a valid ULID to better exercise the v3 ID-based flow.
  6. Return nil for empty metadata (convert.go): convertMetadataToLabels now returns nil for empty metadata, preserving omitempty so empty metadata doesn't serialize as {}.

Validation Results

All tests performed against http://localhost:8888 with the server running from the feat/feature-meter-id branch.

V3 Feature API (/api/v3/openmeter/features)

Test Method Result Notes
Create with meter.id POST PASS Response includes meter.id
Create with meter.filters POST PASS Filters validated and returned
Create static (no meter) POST PASS No meter field in response
Create with invalid meter ID POST PASS Returns 404 with clear error
Create with invalid filter dimension POST PASS Returns 400: "not a valid dimension"
Create with invalid filter operator POST PASS Returns 400 with validation error
Create with duplicate key POST PASS Returns 409 Conflict
Get by ID GET /{id} PASS Full meter info returned
Get non-existent ID GET /{id} PASS Returns 404
List features GET PASS Paginated {data, meta} response
Delete DELETE /{id} PASS Returns 204

Filter Operators Validation

All filter operators tested end-to-end (API -> Postgres storage -> read-back -> ClickHouse query path):

Operator Create Storage Round-trip Query Path
eq PASS {"$eq": "GET"} PASS SelectWhereExpr -> EQ
neq PASS {"$ne": ...} PASS SelectWhereExpr -> NE
in PASS {"$in": [...]} PASS SelectWhereExpr -> In
nin PASS {"$nin": [...]} PASS SelectWhereExpr -> NotIn
exists PASS {"$exists": true} PASS SelectWhereExpr -> IsNotNull/IsNull
contains PASS {"$like": "%api%"} PASS SelectWhereExpr -> Like
ncontains PASS {"$nlike": "%admin%"} PASS SelectWhereExpr -> NotLike
and PASS {"$and": [...]} PASS SelectWhereExpr -> recursive And
or PASS {"$or": [...]} PASS SelectWhereExpr -> recursive Or

Storage uses dual columns: meter_group_by_filters (legacy map[string]string, only for simple eq filters) and advanced_meter_group_by_filters (full FilterString JSON). Reader prefers advanced_meter_group_by_filters when available, falls back to legacy column.

Cross-API Compatibility

Scenario Result
Create in v1, read in v3 PASS - meter.id populated from meter_id column
Create in v3, read in v1 PASS - meterSlug populated via eager-loaded meter edge
Create in v1, delete in v3 PASS
Create in v3, delete in v1 PASS

Bug Fixes Verified

Issue Fix Verified
ForbiddenError returned 400 instead of 403 Changed to http.StatusForbidden YES
Filter round-trip dropped contains/and/or Added missing fields to convertFilterStringToAPIMapItem YES (live server)
FeatureConnector nil causes panic Added nil check in Config.Validate() YES
Empty labels serialized as {} convertMetadataToLabels returns nil for empty YES (live server)

Summary by CodeRabbit

  • New Features
    • Added unit cost configuration for features supporting both manual (fixed amount) and LLM-based (per-token) pricing strategies, including configurable provider, model, and token type pricing.
    • Features can now include optional descriptions.
    • New feature management API endpoints for creating, retrieving, listing, and deleting features with enhanced meter association and filtering validation.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 20, 2026

📝 Walkthrough

Walkthrough

Adds per-feature unit-cost support (manual and LLM), changes Feature meter representation to an explicit id (ULID) with optional filters, adds a Feature description field and DB migration, generates updated API types and discriminator helpers, and implements handlers, conversions, pricing enrichment, tests, and wiring into the v3 server and product-catalog plumbing.

Changes

Cohort / File(s) Summary
API Spec & UnitCost Model
api/spec/packages/aip/src/features/feature.tsp, api/spec/packages/aip/src/features/index.tsp, api/spec/packages/aip/src/features/unitcost.tsp
Feature model changed: meter now { id: Shared.ULID; filters? }; added optional unit_cost?: FeatureUnitCost union with manual and llm variants and LLM pricing/token enums; index import updated.
Generated API Types
api/v3/api.gen.go
Generated Go types for unit-cost (manual/llm), enums, validators, discriminator helpers; Feature and CreateFeature request structs updated (meter.id now value ULID, added UnitCost field).
Handlers: package & wiring
api/v3/handlers/features/handler.go, api/v3/server/server.go, api/v3/server/routes.go
New features handler interface and constructor; server now requires/instantiates Features handler and routes delegate to it.
Handlers: CRUD, validation & errors
api/v3/handlers/features/create.go, api/v3/handlers/features/get.go, api/v3/handlers/features/list.go, api/v3/handlers/features/delete.go, api/v3/handlers/features/error_encoder.go
Create/Get/List/Delete handlers implemented; meter filter validation added; error encoder maps domain errors to HTTP codes; GET supports optional LLM pricing enrichment.
Conversion & Tests
api/v3/handlers/features/convert.go, api/v3/handlers/features/convert_test.go
API↔domain conversion helpers for features, meter filters, metadata/labels, and unit-cost (manual/llm) including enrichment and resolution via llmcost service; comprehensive unit tests.
Handler Tests
api/v3/handlers/features/create_test.go
Tests for validateMeterFilters covering valid/invalid dimension keys and operators.
Product-catalog & Router Wiring
openmeter/productcatalog/driver/feature.go, openmeter/productcatalog/driver/parser.go, openmeter/server/router/router.go
Feature handler construction updated to accept meterService; create-input parser now accepts resolved meterID param and router wires MeterManageService into handler.
DB Schema & Ent / Adapters
openmeter/ent/schema/feature.go, openmeter/productcatalog/adapter/feature.go, openmeter/productcatalog/feature/connector.go, openmeter/productcatalog/feature/feature.go, tools/migrate/migrations/...add-feature-description.*.sql
Added nullable description field to Ent schema, migrations for up/down, adapter persists description, connector and domain Feature structs include optional Description.
Productcatalog driver wiring
openmeter/productcatalog/driver/feature.go, openmeter/productcatalog/driver/parser.go
Create flow resolves meter slug to meter ID via meterService and passes resolved meterID into mapping logic.
Filter utility
pkg/filter/filter.go
Added ReverseContainsPattern to transform LIKE/contains patterns back to literal string values.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant API as Features Handler
    participant NS as NamespaceResolver
    participant MeterSvc as MeterService
    participant FeatureSvc as FeatureConnector
    participant DB as Database

    Client->>API: POST /features (body with meter slug & filters)
    API->>NS: Resolve namespace
    NS-->>API: namespace
    alt meter provided
        API->>MeterSvc: GetMeterByIDOrSlug(slug)
        MeterSvc-->>API: meter (GroupBy dims)
        API->>API: validateMeterFilters(filters, meter)
    end
    API->>FeatureSvc: CreateFeature(inputs with meter id, unit_cost)
    FeatureSvc->>DB: Insert feature
    DB-->>FeatureSvc: created feature
    FeatureSvc-->>API: feature (with unit_cost)
    API-->>Client: 201 Created (Feature JSON)
Loading
sequenceDiagram
    participant Client
    participant API as Features Handler
    participant NS as NamespaceResolver
    participant FeatureSvc as FeatureConnector
    participant LLMCost as LLMCostService

    Client->>API: GET /features/{id}
    API->>NS: Resolve namespace
    NS-->>API: namespace
    API->>FeatureSvc: GetFeature(namespace, id)
    FeatureSvc-->>API: feature (may include unit_cost + meter filters)
    API->>API: convertFeatureToAPI(feature)
    alt unit_cost.type == "llm" and llmcost service available
        API->>API: extract provider/model/token from meter filters or static fields
        API->>LLMCost: ResolvePrice(provider, model, token_type)
        LLMCost-->>API: pricing (per-token rates)
        API->>API: enrichFeatureResponseWithPricing(pricing)
    end
    API-->>Client: 200 OK (Feature JSON, pricing if resolved)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

area/api, area/product-catalog

Suggested reviewers

  • tothandras
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: adding v3 feature handlers and unit cost configuration, which aligns with the primary objectives of the PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/feature-meter-id

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@hekike hekike added the release-note/feature Release note: Exciting New Features label Mar 20, 2026
hekike and others added 11 commits March 21, 2026 07:07
…fixes

- Reference meters by required ID instead of key in v3 Feature API
- Remove unsupported description field from Feature model
- Resolve meter ID to slug in CreateFeature handler via meter service
- Fix unnecessary type conversions flagged by linter
- Fix test to check error returns instead of discarding them

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The FeatureLLMUnitCost and pricing field descriptions implied that only
meter group-by properties were supported. Updated to accurately describe
that both static values and property references work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… costs

AsFeatureLLMUnitCost() silently succeeds on manual union variants,
causing FromFeatureLLMUnitCost() to overwrite the manual cost data.
Add discriminator check before attempting the LLM cast.

Added test for the manual unit cost no-op case that exposed the bug.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Validate filter dimension keys against the meter's GroupBy and reject
unknown filter operators at the API layer before reaching the domain.
Fix remaining unnecessary type conversions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@hekike hekike force-pushed the feat/feature-meter-id branch from 44dcd78 to 48fc691 Compare March 21, 2026 14:29
@hekike hekike changed the base branch from feat/feature-handlers to main March 21, 2026 14:31
@hekike hekike changed the title Feat/feature meter feat(api): v3 feature handlers and unit config Mar 21, 2026
@hekike hekike mentioned this pull request Mar 21, 2026
1 task
@hekike hekike marked this pull request as ready for review March 21, 2026 14:32
@hekike hekike requested a review from a team as a code owner March 21, 2026 14:32
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🧹 Nitpick comments (2)
openmeter/productcatalog/driver/feature.go (1)

130-143: Consider removing the extra meter lookup in the HTTP layer.

Right now meter resolution happens here and again in the connector, which adds an avoidable round-trip on create. You can pass the incoming meter reference through and let connector-side validation/normalization do the single lookup.

♻️ Suggested simplification
-			// Resolve meter slug to meter ID
-			var meterID *string
-			if parsedBody.MeterSlug != nil {
-				m, err := h.meterService.GetMeterByIDOrSlug(ctx, meter.GetMeterInput{
-					Namespace: ns,
-					IDOrSlug:  *parsedBody.MeterSlug,
-				})
-				if err != nil {
-					return emptyFeature, err
-				}
-				meterID = &m.ID
-			}
-
-			return MapFeatureCreateInputsRequest(ns, parsedBody, meterID)
+			return MapFeatureCreateInputsRequest(ns, parsedBody, parsedBody.MeterSlug)

As per coding guidelines: “Performance should be a priority in critical code paths… database operations… should be vetted for potential performance bottlenecks.”

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

In `@openmeter/productcatalog/driver/feature.go` around lines 130 - 143, Remove
the redundant meter lookup in the HTTP layer: stop calling
h.meterService.GetMeterByIDOrSlug and avoid creating meterID here (the block
referencing parsedBody.MeterSlug, m.ID and meterID). Instead pass the incoming
meter reference from parsedBody through into MapFeatureCreateInputsRequest (or
change MapFeatureCreateInputsRequest signature to accept the raw meter slug/ref)
and let the connector-side validation/normalization perform the single lookup;
update any call sites relying on meterID accordingly so the connector handles
resolution/validation.
api/v3/handlers/features/convert.go (1)

280-286: Consider returning nil when there are no labels.

This helper always allocates a map, so labels will serialize as {} even when the feature has no metadata. Returning nil for empty input preserves the omitempty behavior on the API model.

♻️ Possible tweak
 func convertMetadataToLabels(metadata models.Metadata) *api.Labels {
+	if len(metadata) == 0 {
+		return nil
+	}
+
 	labels := make(api.Labels)
 	for k, v := range metadata {
 		labels[k] = v
 	}
 	return &labels
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/v3/handlers/features/convert.go` around lines 280 - 286, The
convertMetadataToLabels helper always allocates and returns a non-nil api.Labels
map, causing empty metadata to serialize as {}; change
convertMetadataToLabels(models.Metadata) to return nil when metadata is empty:
check if metadata is nil or has length 0 and return nil, otherwise allocate
api.Labels, copy entries from metadata into it and return &labels; this
preserves omitempty on the API model while still returning a pointer to
api.Labels when there are entries.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@api/spec/packages/aip/src/features/unitcost.tsp`:
- Around line 31-82: FeatureLLMUnitCost currently allows invalid selector
combinations and leaks server-computed fields into request shapes; fix by
modeling selectors as strict unions and separating request/response fields:
replace the optional pairs (provider/provider_property), (model/model_property),
and (token_type/token_type_property) with explicit union types (e.g.,
ProviderSelector = { provider: string } | { provider_property: string },
ModelSelector = { model: string } | { model_property: string },
TokenTypeSelector = { token_type: string } | { token_type_property: string })
and use those union types as the selector properties on FeatureLLMUnitCost so
the compiler enforces mutual exclusivity and that one must be set; and
remove/move the pricing field out of the input/request shape by marking it
response-only (use your TypeSpec visibility/readOnly mechanism or create
separate FeatureLLMUnitCostCreate vs FeatureLLMUnitCostResponse models) so
server-computed pricing does not appear in generated create requests.
- Around line 84-91: The shared FeatureLLMUnitCost model exposes the computed
pricing field in create requests; split it into two types (e.g.,
FeatureLLMUnitCostCreate and FeatureLLMUnitCostRead), remove the pricing
property from the Create variant (keep pricing?: FeatureLLMUnitCostPricing only
on the Read variant), and update any usages: use FeatureLLMUnitCostCreate in
input types like CreateFeatureRequest/Feature create paths and
FeatureLLMUnitCostRead in response/Feature read paths so clients cannot send the
server-computed pricing field; update any references to FeatureLLMUnitCost,
pricing, Feature, and CreateFeatureRequest accordingly.

In `@api/v3/handlers/features/convert_test.go`:
- Around line 224-236: The test uses a non-ULID MeterID ("tokens_total"), which
hides ID-format regressions; update the fixture used to create feature f by
setting MeterID to a ULID-shaped string (e.g., a valid 26-char ULID) and adjust
the assertion that compares result.Meter.Id (in the convertFeatureToAPI test) to
expect that same ULID (api.ULID("<your-ulid>")) so the v3 ID-based flow is
exercised; locate the MeterID field in the test fixture and the subsequent
assert.Equal against api.ULID to make the values consistent.

In `@api/v3/handlers/features/convert.go`:
- Around line 270-277: The converter convertFilterStringToAPIMapItem is missing
several FilterString fields (contains, ncontains, and/or) so
api.QueryFilterStringMapItem loses operators on round-trip; update the returned
struct in convertFilterStringToAPIMapItem to also set Contains (from
f.Contains), Ncontains (from f.Ncontains or f.Ncontains depending on exact field
name), And (from f.And) and Or (from f.Or) so all supported operators in
filter.FilterString are preserved when serializing to
api.QueryFilterStringMapItem.

In `@api/v3/handlers/features/create.go`:
- Line 90: The current validation calls ValidateQueryFilterStringMapItem(v,
"meter", "filters", k) which only rejects unknown operators; update the handler
to validate incoming filter operators against the exact operator set accepted by
the downstream filter converter (or perform a dry-run conversion) to prevent
runtime failures. Replace or augment the call to
ValidateQueryFilterStringMapItem for the "meter" -> "filters" -> k entry by
either (a) invoking the downstream converter/validator used at query execution
time (e.g., perform a dry-run with the converter you use to build executable
filters) and surface any conversion errors, or (b) explicitly check the operator
tokens in v against the converter’s supported operator list so only supported
operators pass validation. Ensure the error message references the same
"meter"/"filters"/k context when returning validation errors.
- Around line 41-44: Before calling h.meterService.GetMeterByIDOrSlug, validate
that body.Meter.Id is non-empty and return a validation error immediately if it
is empty to avoid the DB lookup; update the create feature handler to perform
this pre-check on body.Meter.Id and only call GetMeterByIDOrSlug when the
id/slug is present, using the same error response path you use for other
validation errors.

In `@api/v3/handlers/features/error_encoder.go`:
- Line 18: Change the HTTP status mapping for feature.ForbiddenError from
BadRequest to Forbidden: update the call to
commonhttp.HandleErrorIfTypeMatches[*feature.ForbiddenError](ctx,
http.StatusBadRequest, err, w) to use http.StatusForbidden instead so
ForbiddenError returns 403; keep the rest of the call/site unchanged.

In `@api/v3/handlers/features/get.go`:
- Line 38: The GET handler currently calls h.connector.GetFeature(...,
feature.IncludeArchivedFeatureFalse) which excludes soft-deleted features;
change the call to include archived features instead (e.g., pass
feature.IncludeArchivedFeatureTrue or remove the exclusion flag according to the
GetFeature signature) so GET returns soft-deleted entities for consistency with
other v3 read endpoints; update the invocation of GetFeature in the handler to
use the "include archived" option and run tests to ensure behavior matches other
read paths.

In `@api/v3/server/server.go`:
- Line 213: The code currently constructs the features handler unconditionally
via featureshandler.New(resolveNamespace, config.FeatureConnector, ...) which
will later panic if config.FeatureConnector is nil; add a fail-fast check during
server initialization to validate config.FeatureConnector is non-nil (e.g.,
before calling featureshandler.New) and return an error or log/fatal with a
clear message if it's missing so the server won't start with a nil
FeatureConnector; ensure the check references config.FeatureConnector and the
handler creation site (featureshandler.New) so maintainers can locate and fix
the issue.

---

Nitpick comments:
In `@api/v3/handlers/features/convert.go`:
- Around line 280-286: The convertMetadataToLabels helper always allocates and
returns a non-nil api.Labels map, causing empty metadata to serialize as {};
change convertMetadataToLabels(models.Metadata) to return nil when metadata is
empty: check if metadata is nil or has length 0 and return nil, otherwise
allocate api.Labels, copy entries from metadata into it and return &labels; this
preserves omitempty on the API model while still returning a pointer to
api.Labels when there are entries.

In `@openmeter/productcatalog/driver/feature.go`:
- Around line 130-143: Remove the redundant meter lookup in the HTTP layer: stop
calling h.meterService.GetMeterByIDOrSlug and avoid creating meterID here (the
block referencing parsedBody.MeterSlug, m.ID and meterID). Instead pass the
incoming meter reference from parsedBody through into
MapFeatureCreateInputsRequest (or change MapFeatureCreateInputsRequest signature
to accept the raw meter slug/ref) and let the connector-side
validation/normalization perform the single lookup; update any call sites
relying on meterID accordingly so the connector handles resolution/validation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3618ede3-0819-4fe3-9710-c66f0d0f840b

📥 Commits

Reviewing files that changed from the base of the PR and between ef40371 and 48fc691.

⛔ Files ignored due to path filters (1)
  • api/v3/openapi.yaml is excluded by !**/openapi.yaml
📒 Files selected for processing (18)
  • api/spec/packages/aip/src/features/feature.tsp
  • api/spec/packages/aip/src/features/index.tsp
  • api/spec/packages/aip/src/features/unitcost.tsp
  • api/v3/api.gen.go
  • api/v3/handlers/features/convert.go
  • api/v3/handlers/features/convert_test.go
  • api/v3/handlers/features/create.go
  • api/v3/handlers/features/create_test.go
  • api/v3/handlers/features/delete.go
  • api/v3/handlers/features/error_encoder.go
  • api/v3/handlers/features/get.go
  • api/v3/handlers/features/handler.go
  • api/v3/handlers/features/list.go
  • api/v3/server/routes.go
  • api/v3/server/server.go
  • openmeter/productcatalog/driver/feature.go
  • openmeter/productcatalog/driver/parser.go
  • openmeter/server/router/router.go

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
api/v3/handlers/features/convert.go (2)

202-204: Silently ignored error on FromFeatureLLMUnitCost.

Line 203 discards the error from FromFeatureLLMUnitCost. If this fails, the pricing won't be set and there's no indication why. Consider at least logging the error for debugging purposes.

🔧 Suggested fix
-	_ = resp.UnitCost.FromFeatureLLMUnitCost(llmCost)
+	if err := resp.UnitCost.FromFeatureLLMUnitCost(llmCost); err != nil {
+		// Log error but don't fail - pricing is optional enrichment
+		return
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/v3/handlers/features/convert.go` around lines 202 - 204, The call to
FromFeatureLLMUnitCost is currently ignoring its returned error (in the block
setting llmCost.Pricing and calling
resp.UnitCost.FromFeatureLLMUnitCost(llmCost)), so failures are silent; change
this to capture the error, check it, and handle it (at minimum log via the
existing logger or return the error) so pricing failures are visible—e.g.,
assign the result to err, and if err != nil, call the appropriate logger or
propagate the error back to the caller from the surrounding function.

91-113: Consider adding a nil guard for u.LLM.

At line 93, you access u.LLM.ProviderProperty without first checking if u.LLM is nil. While domain validation (per UnitCost.Validate()) should ensure LLM is set when Type == UnitCostTypeLLM, a defensive nil check would make this more robust against data corruption or bypassed validation.

🛡️ Optional defensive check
 	case feature.UnitCostTypeLLM:
+		if u.LLM == nil {
+			return out, fmt.Errorf("LLM configuration is nil for LLM unit cost type")
+		}
 		llmCost := api.FeatureLLMUnitCost{}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/v3/handlers/features/convert.go` around lines 91 - 113, The conversion
branch for UnitCostTypeLLM dereferences u.LLM without a nil check; add a
defensive guard at the start of the case (e.g., if u.LLM == nil { return out,
fmt.Errorf("missing LLM data for UnitCostTypeLLM") }) to avoid panics, then
proceed to populate api.FeatureLLMUnitCost and call out.FromFeatureLLMUnitCost
as before; reference symbols: u.LLM, UnitCostTypeLLM, api.FeatureLLMUnitCost,
out.FromFeatureLLMUnitCost (no other changes).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@api/v3/handlers/features/convert.go`:
- Around line 202-204: The call to FromFeatureLLMUnitCost is currently ignoring
its returned error (in the block setting llmCost.Pricing and calling
resp.UnitCost.FromFeatureLLMUnitCost(llmCost)), so failures are silent; change
this to capture the error, check it, and handle it (at minimum log via the
existing logger or return the error) so pricing failures are visible—e.g.,
assign the result to err, and if err != nil, call the appropriate logger or
propagate the error back to the caller from the surrounding function.
- Around line 91-113: The conversion branch for UnitCostTypeLLM dereferences
u.LLM without a nil check; add a defensive guard at the start of the case (e.g.,
if u.LLM == nil { return out, fmt.Errorf("missing LLM data for UnitCostTypeLLM")
}) to avoid panics, then proceed to populate api.FeatureLLMUnitCost and call
out.FromFeatureLLMUnitCost as before; reference symbols: u.LLM, UnitCostTypeLLM,
api.FeatureLLMUnitCost, out.FromFeatureLLMUnitCost (no other changes).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 10cda57a-d0a0-4f25-8c22-f2bfca356203

📥 Commits

Reviewing files that changed from the base of the PR and between 48fc691 and 916bbf7.

📒 Files selected for processing (5)
  • api/v3/handlers/features/convert.go
  • api/v3/handlers/features/convert_test.go
  • api/v3/handlers/features/create.go
  • api/v3/handlers/features/error_encoder.go
  • api/v3/server/server.go
✅ Files skipped from review due to trivial changes (1)
  • api/v3/handlers/features/convert_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • api/v3/handlers/features/error_encoder.go
  • api/v3/server/server.go

@hekike hekike requested a review from tothandras March 22, 2026 04:48
@hekike hekike enabled auto-merge (squash) March 22, 2026 04:48
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
api/v3/api.gen.go (1)

2284-2290: ⚠️ Potential issue | 🟠 Major

Make map-item filters self-recursive.

These new feature shapes still hang meter.filters off QueryFilterStringMapItem, but that filter type recurses through QueryFilterString for and/or instead of recursing to itself. A nested case like {"or":[{"exists":false},{"eq":"x"}]} falls off the typed path and only survives via AdditionalProperties, which is pretty easy for conversion/validation code to miss. I’d point those branches back to the map-item filter schema in the OpenAPI source and regenerate.

As per coding guidelines, Never manually edit generated files marked with '// Code generated by X, DO NOT EDIT.' headers.

Also applies to: 2421-2427

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

In `@api/v3/api.gen.go` around lines 2284 - 2290, The generated API types
currently have QueryFilterStringMapItem pointing to QueryFilterString for its
"and"/"or" recursion, which loses typed map-item branches (e.g., meter.filters
entries); update the OpenAPI schema so the map-item filter schema references
itself for nested "and"/"or" (i.e., make QueryFilterStringMapItem recursive) and
then regenerate the code rather than editing api/v3/api.gen.go directly; locate
the map item schema that produces QueryFilterStringMapItem and change its
"and"/"or" sub-schemas to reference the map-item schema, then run the codegen to
propagate the fix (also apply same change for the other affected schema that
generated lines ~2421-2427).
🧹 Nitpick comments (1)
pkg/filter/filter.go (1)

50-63: Could we add a quick round-trip table test for this helper?

Since escaping logic is easy to regress, a small test matrix ("", "%", "_", "\\", mixed strings) validating ReverseContainsPattern(ptr(ContainsPattern(x))) == x would make this safer long-term.

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

In `@pkg/filter/filter.go` around lines 50 - 63, Add a small table-driven unit
test that verifies round-trip escaping between ContainsPattern and
ReverseContainsPattern: for each test case (e.g., "", "%", "_", "\\", "foo%bar",
"a_b\\c", and mixed strings) call like := ContainsPattern(tc), then got :=
ReverseContainsPattern(like) and assert that dereferenced got equals the
original tc (handle nil pointers consistently). Put the test in the package
tests for these functions (e.g., add a TestContainsPatternRoundTrip using a
slice of inputs and t.Run subtests) and include clear failure messages
indicating the input that failed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@api/v3/api.gen.go`:
- Around line 1622-1625: The generated API includes a response-only Pricing
field on BillingFeatureLLMUnitCost (and CreateFeatureRequest.UnitCost reusing
that union), allowing clients to echo server-resolved pricing into create
requests; fix this by updating the source schema/IDL (not api/v3/api.gen.go) to
separate request vs response shapes—e.g., introduce a create-only variant of
BillingFeatureLLMUnitCost (or remove pricing from the
CreateFeatureRequest.UnitCost union) so the create path does not carry Pricing,
then regenerate the API; ensure you update the corresponding schema entries that
generate the sections around the BillingFeatureLLMUnitCost/Pricing and the
CreateFeatureRequest unit cost union (addresses the duplicated spots including
the other generated block around lines referenced in the comment).

---

Outside diff comments:
In `@api/v3/api.gen.go`:
- Around line 2284-2290: The generated API types currently have
QueryFilterStringMapItem pointing to QueryFilterString for its "and"/"or"
recursion, which loses typed map-item branches (e.g., meter.filters entries);
update the OpenAPI schema so the map-item filter schema references itself for
nested "and"/"or" (i.e., make QueryFilterStringMapItem recursive) and then
regenerate the code rather than editing api/v3/api.gen.go directly; locate the
map item schema that produces QueryFilterStringMapItem and change its "and"/"or"
sub-schemas to reference the map-item schema, then run the codegen to propagate
the fix (also apply same change for the other affected schema that generated
lines ~2421-2427).

---

Nitpick comments:
In `@pkg/filter/filter.go`:
- Around line 50-63: Add a small table-driven unit test that verifies round-trip
escaping between ContainsPattern and ReverseContainsPattern: for each test case
(e.g., "", "%", "_", "\\", "foo%bar", "a_b\\c", and mixed strings) call like :=
ContainsPattern(tc), then got := ReverseContainsPattern(like) and assert that
dereferenced got equals the original tc (handle nil pointers consistently). Put
the test in the package tests for these functions (e.g., add a
TestContainsPatternRoundTrip using a slice of inputs and t.Run subtests) and
include clear failure messages indicating the input that failed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 46eb78ee-deee-44a3-ae6f-29e1b4fb8aaa

📥 Commits

Reviewing files that changed from the base of the PR and between 916bbf7 and b08a659.

⛔ Files ignored due to path filters (12)
  • api/v3/openapi.yaml is excluded by !**/openapi.yaml
  • go.sum is excluded by !**/*.sum, !**/*.sum
  • openmeter/ent/db/feature.go is excluded by !**/ent/db/**
  • openmeter/ent/db/feature/feature.go is excluded by !**/ent/db/**
  • openmeter/ent/db/feature/where.go is excluded by !**/ent/db/**
  • openmeter/ent/db/feature_create.go is excluded by !**/ent/db/**
  • openmeter/ent/db/feature_update.go is excluded by !**/ent/db/**
  • openmeter/ent/db/migrate/schema.go is excluded by !**/ent/db/**
  • openmeter/ent/db/mutation.go is excluded by !**/ent/db/**
  • openmeter/ent/db/runtime.go is excluded by !**/ent/db/**
  • openmeter/ent/db/setorclear.go is excluded by !**/ent/db/**
  • tools/migrate/migrations/atlas.sum is excluded by !**/*.sum, !**/*.sum
📒 Files selected for processing (12)
  • api/spec/packages/aip/src/features/feature.tsp
  • api/spec/packages/aip/src/features/unitcost.tsp
  • api/v3/api.gen.go
  • api/v3/handlers/features/convert.go
  • api/v3/handlers/features/convert_test.go
  • openmeter/ent/schema/feature.go
  • openmeter/productcatalog/adapter/feature.go
  • openmeter/productcatalog/feature/connector.go
  • openmeter/productcatalog/feature/feature.go
  • pkg/filter/filter.go
  • tools/migrate/migrations/20260322041807_add-feature-description.down.sql
  • tools/migrate/migrations/20260322041807_add-feature-description.up.sql
✅ Files skipped from review due to trivial changes (6)
  • tools/migrate/migrations/20260322041807_add-feature-description.up.sql
  • openmeter/ent/schema/feature.go
  • openmeter/productcatalog/feature/feature.go
  • openmeter/productcatalog/feature/connector.go
  • api/v3/handlers/features/convert.go
  • api/v3/handlers/features/convert_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • api/spec/packages/aip/src/features/feature.tsp
  • api/spec/packages/aip/src/features/unitcost.tsp

Comment on lines +1622 to +1625
// Pricing Resolved per-token pricing from the LLM cost database.
// Populated in responses when the provider and model can be determined,
// either from static values or from meter group-by filters with exact matches.
Pricing *BillingFeatureLLMUnitCostPricing `json:"pricing,omitempty"`
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Keep resolved LLM pricing out of create requests.

BillingFeatureLLMUnitCost now carries pricing, and CreateFeatureRequest.UnitCost reuses that same union. That means a client can echo a fetched feature back into create and send server-resolved pricing even though openmeter/productcatalog/feature/unitcost.go:26-66 has no input field for it. I’d split the request/response schemas, or use a create-only variant without pricing, then regenerate this file.

As per coding guidelines, Never manually edit generated files marked with '// Code generated by X, DO NOT EDIT.' headers.

Also applies to: 2297-2300

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

In `@api/v3/api.gen.go` around lines 1622 - 1625, The generated API includes a
response-only Pricing field on BillingFeatureLLMUnitCost (and
CreateFeatureRequest.UnitCost reusing that union), allowing clients to echo
server-resolved pricing into create requests; fix this by updating the source
schema/IDL (not api/v3/api.gen.go) to separate request vs response shapes—e.g.,
introduce a create-only variant of BillingFeatureLLMUnitCost (or remove pricing
from the CreateFeatureRequest.UnitCost union) so the create path does not carry
Pricing, then regenerate the API; ensure you update the corresponding schema
entries that generate the sections around the BillingFeatureLLMUnitCost/Pricing
and the CreateFeatureRequest unit cost union (addresses the duplicated spots
including the other generated block around lines referenced in the comment).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-note/feature Release note: Exciting New Features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants