feat(pricing): add provider-level per-model pricing overrides#1726
feat(pricing): add provider-level per-model pricing overrides#1726
Conversation
📝 WalkthroughSummary by CodeRabbit
WalkthroughAdds provider-level pricing overrides across schema, DB, config, model-catalog, API handlers, framework wiring, and UI: new types/schemas, migration and persistence, compiled override logic in the model catalog, validation and propagation via HTTP handlers, initialization seeding, and a UI tab to edit overrides. Changes
Sequence DiagramsequenceDiagram
participant UI as UI (Pricing Overrides Form)
participant API as Bifrost HTTP Handler
participant ConfigStore as Config Store
participant DB as Database
participant ModelCatalog as Model Catalog
UI->>API: POST/PUT provider (with pricing_overrides)
API->>API: validatePricingOverrides()
API->>ConfigStore: UpdateProvider (includes PricingOverrides)
ConfigStore->>DB: Persist pricing_overrides_json
API->>ModelCatalog: SetProviderPricingOverrides(provider, overrides)
ModelCatalog->>ModelCatalog: Compile & store compiledOverrides
API-->>UI: Return ProviderResponse (includes pricing_overrides)
Client->>API: Request pricing for model
API->>ModelCatalog: getPricing(provider, model, requestType)
ModelCatalog->>ModelCatalog: resolvePricingEntryLocked()
ModelCatalog->>ModelCatalog: selectBestOverride() / apply override patch
ModelCatalog-->>API: Patched pricing
API-->>Client: Return pricing
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
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. Comment |
🧪 Test Suite AvailableThis PR can be tested by a repository admin. |
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
framework/modelcatalog/main.go (1)
694-703:⚠️ Potential issue | 🔴 Critical
NewTestCatalogis missingcompiledOverridesinitialization — will panic on override operations.
SetProviderPricingOverridesandapplyPricingOverrideswrite to / read frommc.compiledOverrides. Since this exported constructor doesn't initialize the map, any caller using it (e.g., external test packages) will get a nil-map write panic.🐛 Proposed fix
func NewTestCatalog(baseModelIndex map[string]string) *ModelCatalog { if baseModelIndex == nil { baseModelIndex = make(map[string]string) } return &ModelCatalog{ - modelPool: make(map[schemas.ModelProvider][]string), - baseModelIndex: baseModelIndex, - pricingData: make(map[string]configstoreTables.TableModelPricing), - done: make(chan struct{}), + modelPool: make(map[schemas.ModelProvider][]string), + baseModelIndex: baseModelIndex, + pricingData: make(map[string]configstoreTables.TableModelPricing), + compiledOverrides: make(map[schemas.ModelProvider][]compiledProviderPricingOverride), + done: make(chan struct{}), } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@framework/modelcatalog/main.go` around lines 694 - 703, NewTestCatalog currently returns a ModelCatalog without initializing the compiledOverrides map, causing nil-map write panics when SetProviderPricingOverrides or applyPricingOverrides touch mc.compiledOverrides; update NewTestCatalog to initialize the compiledOverrides field to an empty map of the same type those methods expect (i.e., make(map[...]{...}) using the exact key/value types used by compiledOverrides in the ModelCatalog) so callers (including external tests) won't panic when overrides are applied.
🧹 Nitpick comments (4)
core/schemas/provider.go (1)
393-399:PricingOverridesadded toProviderConfig— verify deep-copy needs.The
CheckAndSetDefaults()method deep-copiesExtraHeaders(a map) to prevent data races.PricingOverridesis a slice of structs containing*float64pointers. If the sameProviderConfigis shared across goroutines and overrides are mutated (e.g., appended to), the slice header could be a shared-state issue. Since the coding guideline specifically calls out map fields, this is lower risk, but worth noting.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@core/schemas/provider.go` around lines 393 - 399, ProviderConfig now contains a PricingOverrides []ProviderPricingOverride which may be shared across goroutines; update CheckAndSetDefaults in ProviderConfig to perform a deep copy of PricingOverrides: allocate a new slice, copy each ProviderPricingOverride struct into the new slice, and for any pointer fields inside ProviderPricingOverride (e.g., *float64) allocate new memory and copy the pointed values so the resulting slice and pointer targets are independent; reference the PricingOverrides field, the ProviderPricingOverride type, and the ProviderConfig.CheckAndSetDefaults method when making this change.framework/modelcatalog/overrides.go (1)
86-92:literalCharsfor regex patterns is the raw pattern length, not actual literal characters.For regex match type,
literalCharsis set tolen(pattern)which includes metacharacters (^,$,.,*, etc.). SinceliteralCharsis only used as a tie-breaker among overrides of the same match type, and regex is the lowest priority, this is unlikely to cause issues in practice. However, it could produce surprising tie-break results if two regex overrides compete (e.g.,^gpt-.*$vs^gpt-4o$— the longer pattern wins, not the more specific one).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@framework/modelcatalog/overrides.go` around lines 86 - 92, The literalChars field is being set to len(pattern) for regex matches, which counts metacharacters and misorders tie-breaks; update the regex branch in compiledProviderPricingOverride construction to compute literalChars as the number of literal characters in the regex (not raw pattern length). Parse the pattern with regexp/syntax (syntax.Parse) and walk the parsed AST to count Rune/Char/Literal nodes (and count characters in escaped sequences) while ignoring metacharacter/operator nodes (^ $ . * + ? | [] {} () etc.), then assign that count to result.literalChars instead of len(pattern); keep the existing regexp.Compile and error handling for re and retain result.regex assignment.framework/modelcatalog/overrides_test.go (1)
25-27: Consider usingschemas.Ptr()instead of the localfloatPtrhelper.The
floatPtrfunction at lines 25–27 is redundant. The codebase convention is to useschemas.Ptr()(orbifrost.Ptr()) for pointer creation, which is already available through your imports and does exactly the same thing.♻️ Proposed change
-func floatPtr(v float64) *float64 { - return &v -}Replace the 4 calls to
floatPtr(...)withschemas.Ptr(...):
- Line 337:
CacheReadInputImageTokenCost: floatPtr(0.2)→schemas.Ptr(0.2)- Line 343:
InputCostPerToken: floatPtr(3)→schemas.Ptr(3)- Line 344:
CacheReadInputTokenCost: floatPtr(0.9)→schemas.Ptr(0.9)- Line 345:
OutputCostPerImageToken: floatPtr(1.2)→schemas.Ptr(1.2)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@framework/modelcatalog/overrides_test.go` around lines 25 - 27, Remove the redundant local helper floatPtr and replace its usages with the shared helper schemas.Ptr: delete the floatPtr function and change calls like floatPtr(0.2) to schemas.Ptr(0.2) (and similarly for floatPtr(3), floatPtr(0.9), floatPtr(1.2)); ensure replacements are applied where fields like CacheReadInputImageTokenCost, InputCostPerToken, CacheReadInputTokenCost, and OutputCostPerImageToken are initialized so imports providing schemas.Ptr are used consistently.ui/app/workspace/providers/dialogs/providerConfigSheet.tsx (1)
11-11: Consider importing from the barrel export for consistency with Line 8.
PricingOverridesFormFragmentis now re-exported from../fragments/index.ts. The three fragments imported on Line 8 all come from the barrel; consolidating here avoids a mixed pattern within the same file.♻️ Proposed refactor
- import { ApiStructureFormFragment, GovernanceFormFragment, ProxyFormFragment } from "../fragments"; - import { NetworkFormFragment } from "../fragments/networkFormFragment"; - import { PerformanceFormFragment } from "../fragments/performanceFormFragment"; - import { PricingOverridesFormFragment } from "../fragments/pricingOverridesFormFragment"; + import { + ApiStructureFormFragment, + GovernanceFormFragment, + NetworkFormFragment, + PerformanceFormFragment, + PricingOverridesFormFragment, + ProxyFormFragment, + } from "../fragments";🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ui/app/workspace/providers/dialogs/providerConfigSheet.tsx` at line 11, The import for PricingOverridesFormFragment should be changed to use the barrel re-export like the other fragments to keep imports consistent; locate the import statement that currently reads "import { PricingOverridesFormFragment } from \"../fragments/pricingOverridesFormFragment\"" and update it to import from the barrel (the same module used by the other three fragments) so all fragment imports come from "../fragments" (i.e., import { PricingOverridesFormFragment } from "../fragments").
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@transports/bifrost-http/handlers/providers.go`:
- Around line 227-229: Change the error message passed to SendError so it starts
with a lowercase letter per Go error conventions: replace "Invalid pricing
overrides: %v" with "invalid pricing overrides: %v" wherever SendError is called
after validatePricingOverrides(payload.PricingOverrides) and the other
occurrence later in the file (both calls that pass the formatted error string).
Ensure you update both places that construct that message so the error string
begins with "invalid".
In `@transports/bifrost-http/lib/config.go`:
- Line 2170: When rebuilding the provider config in loadDefaultProviders,
include the PricingOverrides field so
applyProviderPricingOverrides(config.ModelCatalog, config.Providers) can see and
apply stored overrides; update the ProviderConfig construction in
loadDefaultProviders to copy the existing PricingOverrides from the persisted
config (or config.Providers entry) into the new ProviderConfig instance so
overrides are not dropped on restart.
In `@transports/config.schema.json`:
- Around line 1620-1668: The request_types array in the
provider_pricing_override schema currently accepts any string; tighten it by
adding an enum of allowed request types and referencing that enum in the
request_types items schema. Update the "provider_pricing_override" ->
"request_types" -> "items" to use a $ref to a new or existing definition (e.g.,
add a "$defs/request_type" or "$defs/pricing_request_type" listing the supported
strings such as "chat", "completion", "embedding", "image", "audio", "video",
etc.), so the schema enforces only supported values across the stack.
In `@ui/app/workspace/providers/dialogs/providerConfigSheet.tsx`:
- Around line 39-42: The new "pricing-overrides" tab trigger is missing a
data-testid; update the shared TabsTrigger rendering (in the ProviderConfigSheet
component where tabs are mapped/rendered) to include a data-testid attribute
built from the tab id using the project's convention (e.g.,
data-testid={`provider-tab-${tab.id}`}), so every tab (including the
"pricing-overrides" tab from the tabs array) gets a consistent test id; ensure
the attribute is added to the TabsTrigger element (the shared template that
renders each tab) rather than per-tab instances.
In `@ui/app/workspace/providers/fragments/pricingOverridesFormFragment.tsx`:
- Around line 105-108: The Reset Button in pricingOverridesFormFragment (the
<Button> element that uses onClick={onReset} and props
disabled={!hasUpdateProviderAccess || !isDirty}) is missing a data-testid; add a
data-testid attribute (e.g., data-testid="pricing-overrides-reset" or matching
existing workspace test id conventions) to that Button so all interactive
workspace elements include a test id while keeping the existing onClick,
variant, type and disabled logic unchanged.
- Around line 31-34: The effect currently resets overridesJSON and clears
validation errors whenever initialValue or provider.name changes, which will
clobber in-progress edits; modify the useEffect (the effect that calls
setOverridesJSON and setValidationError) to first check the form's dirty state
(e.g., form.formState.isDirty) and only perform the reset when the form is not
dirty (clean), otherwise skip the update so user edits are preserved; ensure you
reference initialValue and provider.name as the triggering deps but gate the
reset behind the form.formState.isDirty check.
---
Outside diff comments:
In `@framework/modelcatalog/main.go`:
- Around line 694-703: NewTestCatalog currently returns a ModelCatalog without
initializing the compiledOverrides map, causing nil-map write panics when
SetProviderPricingOverrides or applyPricingOverrides touch mc.compiledOverrides;
update NewTestCatalog to initialize the compiledOverrides field to an empty map
of the same type those methods expect (i.e., make(map[...]{...}) using the exact
key/value types used by compiledOverrides in the ModelCatalog) so callers
(including external tests) won't panic when overrides are applied.
---
Nitpick comments:
In `@core/schemas/provider.go`:
- Around line 393-399: ProviderConfig now contains a PricingOverrides
[]ProviderPricingOverride which may be shared across goroutines; update
CheckAndSetDefaults in ProviderConfig to perform a deep copy of
PricingOverrides: allocate a new slice, copy each ProviderPricingOverride struct
into the new slice, and for any pointer fields inside ProviderPricingOverride
(e.g., *float64) allocate new memory and copy the pointed values so the
resulting slice and pointer targets are independent; reference the
PricingOverrides field, the ProviderPricingOverride type, and the
ProviderConfig.CheckAndSetDefaults method when making this change.
In `@framework/modelcatalog/overrides_test.go`:
- Around line 25-27: Remove the redundant local helper floatPtr and replace its
usages with the shared helper schemas.Ptr: delete the floatPtr function and
change calls like floatPtr(0.2) to schemas.Ptr(0.2) (and similarly for
floatPtr(3), floatPtr(0.9), floatPtr(1.2)); ensure replacements are applied
where fields like CacheReadInputImageTokenCost, InputCostPerToken,
CacheReadInputTokenCost, and OutputCostPerImageToken are initialized so imports
providing schemas.Ptr are used consistently.
In `@framework/modelcatalog/overrides.go`:
- Around line 86-92: The literalChars field is being set to len(pattern) for
regex matches, which counts metacharacters and misorders tie-breaks; update the
regex branch in compiledProviderPricingOverride construction to compute
literalChars as the number of literal characters in the regex (not raw pattern
length). Parse the pattern with regexp/syntax (syntax.Parse) and walk the parsed
AST to count Rune/Char/Literal nodes (and count characters in escaped sequences)
while ignoring metacharacter/operator nodes (^ $ . * + ? | [] {} () etc.), then
assign that count to result.literalChars instead of len(pattern); keep the
existing regexp.Compile and error handling for re and retain result.regex
assignment.
In `@ui/app/workspace/providers/dialogs/providerConfigSheet.tsx`:
- Line 11: The import for PricingOverridesFormFragment should be changed to use
the barrel re-export like the other fragments to keep imports consistent; locate
the import statement that currently reads "import { PricingOverridesFormFragment
} from \"../fragments/pricingOverridesFormFragment\"" and update it to import
from the barrel (the same module used by the other three fragments) so all
fragment imports come from "../fragments" (i.e., import {
PricingOverridesFormFragment } from "../fragments").
ui/app/workspace/providers/fragments/pricingOverridesFormFragment.tsx
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
framework/modelcatalog/overrides_test.go (1)
47-65: Prefer pointer helpers over address-of literals for overrides.Use a pointer helper (already in scope via
schemas.Ptr) instead of&valuefor consistency with repo conventions.♻️ Example (apply similarly to other override literals)
- InputCostPerToken: &wildcard, + InputCostPerToken: schemas.Ptr(wildcard),Based on learnings: In the maximhq/bifrost repository, prefer using bifrost.Ptr() to create pointers instead of the address operator (&) even when & would be valid syntactically. Apply this consistently across all code paths, including test utilities, to improve consistency and readability. Replace occurrences of &value where a *T is expected with bifrost.Ptr(value) (or an equivalent call) and ensure the function is in scope and used correctly for the target pointer type.
Also applies to: 86-99, 118-132, 151-158, 177-184, 205-212, 234-241, 267-280, 299-312
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@framework/modelcatalog/overrides_test.go` around lines 47 - 65, Replace address-of literals used for pointer fields in the pricing override test with the pointer helper available in scope (schemas.Ptr) for consistency: where the diff shows &wildcard, ®ex, &exact (and the other occurrences noted), call schemas.Ptr(wildcard), schemas.Ptr(regex), schemas.Ptr(exact) (and similarly for other numeric/string variables used as *T in SetProviderPricingOverrides calls). Update the literals passed to mc.SetProviderPricingOverrides and any other override structs (e.g., InputCostPerToken, OutputCostPerToken, etc.) to use schemas.Ptr(...) instead of &value so the tests use the repository pointer helper consistently.ui/app/workspace/providers/fragments/pricingOverridesFormFragment.tsx (1)
3-8: Use relative imports for ui/lib modules.Switch
@/lib/*imports to relative paths from this file.♻️ Suggested import adjustment
-import { getErrorMessage, setProviderFormDirtyState, useAppDispatch } from "@/lib/store"; -import { useUpdateProviderMutation } from "@/lib/store/apis/providersApi"; -import { ModelProvider } from "@/lib/types/config"; -import { providerPricingOverrideSchema } from "@/lib/types/schemas"; +import { getErrorMessage, setProviderFormDirtyState, useAppDispatch } from "../../../../lib/store"; +import { useUpdateProviderMutation } from "../../../../lib/store/apis/providersApi"; +import { ModelProvider } from "../../../../lib/types/config"; +import { providerPricingOverrideSchema } from "../../../../lib/types/schemas";As per coding guidelines: ui/**/*.{ts,tsx}: TypeScript/React code must use Prettier formatting and follow Next.js App Router patterns. Use relative imports from the ui/lib directory for constants, utilities, and types.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ui/app/workspace/providers/fragments/pricingOverridesFormFragment.tsx` around lines 3 - 8, Replace the absolute imports that reference "@/lib/*" with relative imports that point to the ui/lib directory for the utilities and types used in this fragment: getErrorMessage, setProviderFormDirtyState, useAppDispatch, useUpdateProviderMutation, ModelProvider, and providerPricingOverrideSchema; update the import statements in pricingOverridesFormFragment.tsx so they use relative paths instead of "@/lib/..." to comply with the ui/ project import conventions and Prettier/Next.js App Router patterns.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@ui/app/workspace/providers/fragments/pricingOverridesFormFragment.tsx`:
- Around line 29-38: The effect that resets the form is gating on hasUserEdits,
which stays true after the first edit and blocks sync when provider changes;
change the guard to use isDirty (computed as hasUserEdits && overridesJSON !==
initialValue) so the form only blocks reset when truly dirty. Update the
useEffect callback to `if (isDirty) return;` and ensure the dependency array
includes isDirty (and provider.name) so when overridesJSON or initialValue
revert and isDirty flips false the effect will run and call
setOverridesJSON(initialValue) and setValidationError("").
---
Nitpick comments:
In `@framework/modelcatalog/overrides_test.go`:
- Around line 47-65: Replace address-of literals used for pointer fields in the
pricing override test with the pointer helper available in scope (schemas.Ptr)
for consistency: where the diff shows &wildcard, ®ex, &exact (and the other
occurrences noted), call schemas.Ptr(wildcard), schemas.Ptr(regex),
schemas.Ptr(exact) (and similarly for other numeric/string variables used as *T
in SetProviderPricingOverrides calls). Update the literals passed to
mc.SetProviderPricingOverrides and any other override structs (e.g.,
InputCostPerToken, OutputCostPerToken, etc.) to use schemas.Ptr(...) instead of
&value so the tests use the repository pointer helper consistently.
In `@ui/app/workspace/providers/fragments/pricingOverridesFormFragment.tsx`:
- Around line 3-8: Replace the absolute imports that reference "@/lib/*" with
relative imports that point to the ui/lib directory for the utilities and types
used in this fragment: getErrorMessage, setProviderFormDirtyState,
useAppDispatch, useUpdateProviderMutation, ModelProvider, and
providerPricingOverrideSchema; update the import statements in
pricingOverridesFormFragment.tsx so they use relative paths instead of
"@/lib/..." to comply with the ui/ project import conventions and
Prettier/Next.js App Router patterns.
ui/app/workspace/providers/fragments/pricingOverridesFormFragment.tsx
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
🧹 Nitpick comments (1)
transports/bifrost-http/handlers/providers.go (1)
1052-1089: Replace non-deterministic map iteration with an ordered field list.
validatePricingOverrideNonNegativeFieldsusesmap[string]*float64to iterate over optional cost fields. Go map iteration order is random, so if multiple fields are negative, the reported field name varies between calls, making errors harder to debug consistently.All 26
*float64fields fromschemas.ProviderPricingOverrideare covered in validation, but consider using an ordered slice of field structs instead:♻️ Proposed refactor
+type pricingField struct { + name string + ptr *float64 +} + func validatePricingOverrideNonNegativeFields(index int, override schemas.ProviderPricingOverride) error { - optionalValues := map[string]*float64{ - "input_cost_per_token": override.InputCostPerToken, - "output_cost_per_token": override.OutputCostPerToken, - // ... 26 total entries ... - "cache_read_input_image_token_cost": override.CacheReadInputImageTokenCost, - } - - for fieldName, value := range optionalValues { - if value != nil && *value < 0 { - return fmt.Errorf("override[%d]: %s must be non-negative", index, fieldName) - } - } + fields := []pricingField{ + {"input_cost_per_token", override.InputCostPerToken}, + {"output_cost_per_token", override.OutputCostPerToken}, + // ... ordered list of all 26 fields ... + {"cache_read_input_image_token_cost", override.CacheReadInputImageTokenCost}, + } + + for _, f := range fields { + if f.ptr != nil && *f.ptr < 0 { + return fmt.Errorf("override[%d]: %s must be non-negative", index, f.name) + } + } return nil }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@transports/bifrost-http/handlers/providers.go` around lines 1052 - 1089, The function validatePricingOverrideNonNegativeFields uses an unordered map to check many optional *float64 fields which causes non-deterministic error reporting; replace the map with a deterministic ordered slice (e.g., []struct{name string; val *float64}) listing each ProviderPricingOverride field in the desired order and iterate that slice to check for nil/negative values, returning the formatted error on the first failure; update references to the fields (the 26 keys currently used) in the new slice so behaviour and messages remain identical but deterministic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@transports/bifrost-http/handlers/providers.go`:
- Around line 1052-1089: The function validatePricingOverrideNonNegativeFields
uses an unordered map to check many optional *float64 fields which causes
non-deterministic error reporting; replace the map with a deterministic ordered
slice (e.g., []struct{name string; val *float64}) listing each
ProviderPricingOverride field in the desired order and iterate that slice to
check for nil/negative values, returning the formatted error on the first
failure; update references to the fields (the 26 keys currently used) in the new
slice so behaviour and messages remain identical but deterministic.

Summary
Implements provider-level per-model pricing overrides with pattern-based matching and field-level patching over datasheet pricing.
Users can now define
pricing_overridesunder a provider and override selected pricing fields (token, cache, batch, image, tiered pricing, etc.) without changing remote datasheet sync or core cost formulas.What Changed
1. Schema & Config Support
ProviderPricingOverridePricingOverrideMatchType(exact,wildcard,regex)pricing_overridesin provider config2. Configstore Persistence
pricing_overrides_json3. Override Engine (Model Catalog)
exact>wildcard>regex4. Pricing Resolution Integration
5. Runtime Lifecycle Wiring
6. API Validation
7. UI Support
8. Tests Added / Expanded
Type of Change
Affected Areas
How to Test
Automated Tests