From abc4d0459573432f9471747ecb643c9a872c145c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Fri, 29 May 2026 00:18:15 +0300 Subject: [PATCH] Add per-model pricing override functionality and resolvePricing function --- CHANGELOG.md | 7 +++++ README.md | 4 +++ internal/cli/compress.go | 2 +- internal/cli/dryrun.go | 2 +- internal/cli/pricing.go | 40 +++++++++++++++++++++++++ internal/cli/pricing_test.go | 57 ++++++++++++++++++++++++++++++++++++ internal/cli/review.go | 6 ++-- internal/config/config.go | 15 ++++++++++ 8 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 internal/cli/pricing.go create mode 100644 internal/cli/pricing_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 3953144..714eef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v comments is now derived from the PR's `url` field (which always points at the base repo, including cross-fork PRs). ### Added +- **Per-model pricing override (OQ-09).** `providers..pricing.` + in config overrides the built-in `$/1M`-token rate snapshot used by the + cost preflight, the verbose footer, and cached-cost figures. Fields: + `input_per_1m`, `output_per_1m`, `cached_input_per_1m`; zero/omitted + fields fall back to the built-in value (partial override OK). Edited in + the config file and shown by `commitbrief config show`. Useful when the + hard-coded snapshot drifts or for a negotiated rate. - **CI integration: the `commitbrief-action` GitHub Action.** A separate repo (`CommitBrief/commitbrief-action`) ships a composite action that runs CommitBrief on pull requests — either posting inline review diff --git a/README.md b/README.md index 46f239f..53bb3f6 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,10 @@ provider: anthropic # default provider providers: anthropic: model: claude-opus-4-7 + pricing: # optional: override built-in $/1M rates + claude-opus-4-7: # (cost preflight / verbose footer / cache) + input_per_1m: 15.0 + output_per_1m: 75.0 # omitted fields keep the built-in value openai: model: gpt-4o ollama: diff --git a/internal/cli/compress.go b/internal/cli/compress.go index a397dc1..4138e8a 100644 --- a/internal/cli/compress.go +++ b/internal/cli/compress.go @@ -77,7 +77,7 @@ func newCompressCmd() *cobra.Command { latency := time.Since(start) percent, deltaTokens := result.Savings() - pricing := prov.Pricing(model) + pricing := resolvePricing(app.Config, prov, model) // Cost-per-review input savings: input-token rate * delta tokens. perReviewSavedUSD := float64(deltaTokens) * pricing.InputPer1M / 1_000_000 diff --git a/internal/cli/dryrun.go b/internal/cli/dryrun.go index 243f406..5f71842 100644 --- a/internal/cli/dryrun.go +++ b/internal/cli/dryrun.go @@ -85,7 +85,7 @@ func newDryRunCmd() *cobra.Command { modelName = prov.DefaultModel() } contextWindow = prov.ContextWindow(modelName) - estCost = prov.Pricing(modelName).Cost(provider.Usage{ + estCost = resolvePricing(app.Config, prov, modelName).Cost(provider.Usage{ InputTokens: inputTokens, OutputTokens: outputTokens, }) diff --git a/internal/cli/pricing.go b/internal/cli/pricing.go new file mode 100644 index 0000000..30e9811 --- /dev/null +++ b/internal/cli/pricing.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package cli + +import ( + "github.com/CommitBrief/commitbrief/internal/config" + "github.com/CommitBrief/commitbrief/internal/provider" +) + +// resolvePricing returns the effective per-model pricing: the provider's +// built-in rate table, with any user override from +// `providers..pricing.` merged on top (OQ-09). Only +// non-zero override fields apply, so a partial override (e.g. just +// output_per_1m) keeps the built-in value for the rest. Used everywhere a +// dollar figure is computed (cost preflight, verbose footer, cached cost, +// dry-run, compress savings) so the override is honored uniformly. +func resolvePricing(cfg *config.Config, prov provider.Provider, model string) provider.Pricing { + base := prov.Pricing(model) + if cfg == nil { + return base + } + pc, ok := cfg.Providers[cfg.Provider] + if !ok { + return base + } + mp, ok := pc.Pricing[model] + if !ok { + return base + } + if mp.InputPer1M != 0 { + base.InputPer1M = mp.InputPer1M + } + if mp.OutputPer1M != 0 { + base.OutputPer1M = mp.OutputPer1M + } + if mp.CachedInputPer1M != 0 { + base.CachedInputPer1M = mp.CachedInputPer1M + } + return base +} diff --git a/internal/cli/pricing_test.go b/internal/cli/pricing_test.go new file mode 100644 index 0000000..592b56c --- /dev/null +++ b/internal/cli/pricing_test.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package cli + +import ( + "testing" + + "github.com/CommitBrief/commitbrief/internal/config" + "github.com/CommitBrief/commitbrief/internal/provider" + "github.com/CommitBrief/commitbrief/internal/provider/mock" +) + +func TestResolvePricing(t *testing.T) { + const model = "mock-model" + base := provider.Pricing{InputPer1M: 1.0, OutputPer1M: 2.0, CachedInputPer1M: 0.5} + prov := mock.New() + prov.PricingValue = base + + withOverride := func(mp config.ModelPricing) *config.Config { + return &config.Config{ + Provider: "mock", + Providers: map[string]config.ProviderConfig{"mock": {Pricing: map[string]config.ModelPricing{model: mp}}}, + } + } + + // nil cfg → built-in. + if got := resolvePricing(nil, prov, model); got != base { + t.Errorf("nil cfg should yield built-in, got %+v", got) + } + + // No pricing map → built-in. + cfgNone := &config.Config{Provider: "mock", Providers: map[string]config.ProviderConfig{"mock": {}}} + if got := resolvePricing(cfgNone, prov, model); got != base { + t.Errorf("no override should yield built-in, got %+v", got) + } + + // Full override replaces all three. + got := resolvePricing(withOverride(config.ModelPricing{InputPer1M: 9, OutputPer1M: 8, CachedInputPer1M: 7}), prov, model) + if got.InputPer1M != 9 || got.OutputPer1M != 8 || got.CachedInputPer1M != 7 { + t.Errorf("full override = %+v, want {9,8,7}", got) + } + + // Partial override (only output) keeps built-in for the rest. + got = resolvePricing(withOverride(config.ModelPricing{OutputPer1M: 99}), prov, model) + if got.InputPer1M != 1.0 || got.OutputPer1M != 99 || got.CachedInputPer1M != 0.5 { + t.Errorf("partial override = %+v, want {1.0, 99, 0.5}", got) + } + + // Override for a different model → no effect on this model. + cfgOther := &config.Config{ + Provider: "mock", + Providers: map[string]config.ProviderConfig{"mock": {Pricing: map[string]config.ModelPricing{"other": {InputPer1M: 50}}}}, + } + if got := resolvePricing(cfgOther, prov, model); got != base { + t.Errorf("override for a different model should not apply, got %+v", got) + } +} diff --git a/internal/cli/review.go b/internal/cli/review.go index 21cd177..6031da1 100644 --- a/internal/cli/review.go +++ b/internal/cli/review.go @@ -238,7 +238,7 @@ func runReview(cmd *cobra.Command, scope reviewScopeFlags, diffArgs []string) er Cached: true, Timestamp: entry.CreatedAt, Usage: usage, - Cost: prov.Pricing(model).Cost(usage), + Cost: resolvePricing(app.Config, prov, model).Cost(usage), Files: parsed.FileCount(), LinesAdded: parsed.AddedLines(), LinesRemoved: parsed.DeletedLines(), @@ -284,7 +284,7 @@ func runReview(cmd *cobra.Command, scope reviewScopeFlags, diffArgs []string) er InputTokens: p.EstimatedTokens(), OutputTokens: estimateOutputTokens(p.EstimatedTokens()), } - estCost := prov.Pricing(model).Cost(estUsage) + estCost := resolvePricing(app.Config, prov, model).Cost(estUsage) prog.Pause() if abort := handleCostPreflight(cmd, app, estCost, stdinReader); abort { return errors.New(app.Catalog.T("cost.aborted_user")) @@ -356,7 +356,7 @@ func runReview(cmd *cobra.Command, scope reviewScopeFlags, diffArgs []string) er Model: respModel, Lang: app.Lang.Code, Usage: usage, - Cost: prov.Pricing(respModel).Cost(usage), + Cost: resolvePricing(app.Config, prov, respModel).Cost(usage), Latency: latency, Timestamp: time.Now().UTC(), Files: parsed.FileCount(), diff --git a/internal/config/config.go b/internal/config/config.go index 5a8c7a2..63b6848 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,6 +37,21 @@ type ProviderConfig struct { APIKey string `yaml:"api_key,omitempty"` Model string `yaml:"model,omitempty"` BaseURL string `yaml:"base_url,omitempty"` + + // Pricing overrides the built-in per-model rate table (OQ-09), keyed + // by model name. Useful when the hard-coded snapshot drifts or for a + // negotiated rate. Zero fields fall back to the built-in value, so a + // partial override (e.g. only output_per_1m) is allowed. Consumed by + // the cost preflight, verbose footer, and cached-cost figures via + // resolvePricing (internal/cli). + Pricing map[string]ModelPricing `yaml:"pricing,omitempty"` +} + +// ModelPricing is a per-1M-token rate override for one model. +type ModelPricing struct { + InputPer1M float64 `yaml:"input_per_1m,omitempty"` + OutputPer1M float64 `yaml:"output_per_1m,omitempty"` + CachedInputPer1M float64 `yaml:"cached_input_per_1m,omitempty"` } type OutputConfig struct {