This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git


The following commit(s) were added to refs/heads/master by this push:
     new 834dd28  feat(pricing): edit model prices and sync them from models.dev
834dd28 is described below

commit 834dd28725c9425d259c6daf9268048d99c9e308
Author: Yang Luo <[email protected]>
AuthorDate: Wed Sep 2 22:28:00 2026 +0800

    feat(pricing): edit model prices and sync them from models.dev
---
 controllers/llm_price.go                       | 154 +++++++++
 object/llm_price.go                            | 184 +++++++++++
 object/llm_pricing.go                          |  70 ++++-
 object/llm_record.go                           |  17 +
 object/models_dev.go                           | 418 +++++++++++++++++++++++++
 object/ormer.go                                |   7 +
 routers/router.go                              |   6 +
 web/src/App.tsx                                |   2 +
 web/src/backend/LlmPriceBackend.ts             |  49 +++
 web/src/components/usage/models-dev-picker.tsx | 152 +++++++++
 web/src/components/usage/price-edit-dialog.tsx | 201 ++++++++++++
 web/src/components/usage/usage-hero.tsx        |   9 +-
 web/src/lib/usage.ts                           |  12 +
 web/src/locales/en/data.json                   |  36 ++-
 web/src/locales/zh/data.json                   |  36 ++-
 web/src/nav.ts                                 |   2 +
 web/src/pages/PricingPage.tsx                  | 300 ++++++++++++++++++
 web/src/types.ts                               |  54 ++++
 18 files changed, 1698 insertions(+), 11 deletions(-)

diff --git a/controllers/llm_price.go b/controllers/llm_price.go
new file mode 100644
index 0000000..4c86448
--- /dev/null
+++ b/controllers/llm_price.go
@@ -0,0 +1,154 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controllers
+
+import (
+       "encoding/json"
+       "sort"
+       "strings"
+
+       "github.com/apache/casbin-gateway/object"
+)
+
+// Prices decide what every recorded request is said to have cost, so like the
+// records themselves they are the administrator's to read and to change.
+
+// GetLlmPrices lists every price in effect, built-in ones included, each with
+// the layer it came from.
+func (c *ApiController) GetLlmPrices() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       c.ResponseOk(object.GetLlmPriceViews())
+}
+
+// UpdateLlmPrice stores one price, which from then on overrides whatever the
+// built-in table or the pricing file says for that model.
+func (c *ApiController) UpdateLlmPrice() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       entry := object.LlmPriceEntry{}
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &entry); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       // A price arriving from the page is somebody's own correction, 
whatever the
+       // row it was opened from was written by.
+       entry.Source = object.LlmPriceSourceManual
+
+       if err := object.SetLlmPriceEntry(&entry); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       c.ResponseOk(entry)
+}
+
+// DeleteLlmPrice drops a stored price, putting the built-in one for that model
+// back in effect where there is one.
+func (c *ApiController) DeleteLlmPrice() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       model := c.Input().Get("model")
+       if strings.TrimSpace(model) == "" {
+               c.ResponseError("a price needs a model name")
+               return
+       }
+       if err := object.DeleteLlmPriceEntry(model); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       c.ResponseOk(true)
+}
+
+// SearchModelsDevModels answers the dialog that adds a price for a model this
+// machine has not run yet, so the rates are copied from the catalogue rather
+// than typed in from a vendor's pricing page.
+func (c *ApiController) SearchModelsDevModels() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       models, err := object.SearchModelsDev(c.Input().Get("q"), 
c.Input().Get("refresh") == "true")
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       c.ResponseOk(models)
+}
+
+// SyncModelsDevPrices prices every model this machine has been seen running,
+// from the models.dev catalogue. It answers with what it did, because the half
+// that matters is the models the catalogue could not price: those are the ones
+// still costing nothing on the Usage page.
+func (c *ApiController) SyncModelsDevPrices() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       result, err := object.SyncModelsDevPrices(seenModels(), 
c.Input().Get("refresh") == "true")
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       c.ResponseOk(result)
+}
+
+// seenModels is every model name this machine has a record of running: the
+// relayed requests, the agents' own transcripts, and the models the configured
+// providers say they serve. A source that cannot be read contributes nothing
+// rather than failing the sync, which the other two can still be useful 
without.
+func seenModels() []string {
+       found := map[string]bool{}
+       add := func(name string) {
+               name = strings.ToLower(strings.TrimSpace(name))
+               if name != "" {
+                       found[name] = true
+               }
+       }
+
+       if models, err := object.GetSeenLlmModels(); err == nil {
+               for _, model := range models {
+                       add(model)
+               }
+       }
+
+       for _, stat := range object.GetAgentUsage(historicalSessions(""), 
"").Models {
+               add(stat.Name)
+       }
+
+       if providers, err := object.GetProviders(""); err == nil {
+               for _, provider := range providers {
+                       for _, model := range provider.Models {
+                               add(model)
+                       }
+               }
+       }
+
+       models := make([]string, 0, len(found))
+       for model := range found {
+               models = append(models, model)
+       }
+       sort.Strings(models)
+       return models
+}
diff --git a/object/llm_price.go b/object/llm_price.go
new file mode 100644
index 0000000..f497de3
--- /dev/null
+++ b/object/llm_price.go
@@ -0,0 +1,184 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package object
+
+import (
+       "errors"
+       "sort"
+       "strings"
+
+       "github.com/apache/casbin-gateway/util"
+)
+
+// Where a price in effect came from. A stored entry carries one of the last
+// two; the first two are computed when the table is merged.
+const (
+       LlmPriceSourceBuiltIn   = "built-in"
+       LlmPriceSourceFile      = "file"
+       LlmPriceSourceManual    = "manual"
+       LlmPriceSourceModelsDev = "models.dev"
+)
+
+// LlmPriceEntry is a stored override of what a model costs, edited on the
+// Pricing page or written by a models.dev sync. Model is matched the way the
+// built-in table is: as a substring of the name a request arrives under, so 
one
+// entry covers the dated and vendor-prefixed shapes of the same model.
+type LlmPriceEntry struct {
+       Model       string `xorm:"varchar(255) notnull pk" json:"model"`
+       DisplayName string `xorm:"varchar(255)" json:"displayName"`
+
+       Input        float64 `xorm:"double" json:"input"`
+       Output       float64 `xorm:"double" json:"output"`
+       CacheWrite   float64 `xorm:"double" json:"cacheWrite"`
+       CacheRead    float64 `xorm:"double" json:"cacheRead"`
+       CacheWrite1h float64 `xorm:"double" json:"cacheWrite1h"`
+
+       // Source is what wrote this row: a sync overwrites its own rows and 
leaves
+       // a hand-edited one alone, so a correction survives the next sync.
+       Source      string `xorm:"varchar(20)" json:"source"`
+       UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
+}
+
+// LlmPriceView is one model's price as it is actually in effect, with the 
layer
+// it came from. The Pricing page lists these rather than the stored rows: a
+// built-in price nothing overrides is still a price, and hiding it would make
+// the table look emptier than the costing is.
+type LlmPriceView struct {
+       Model       string `json:"model"`
+       DisplayName string `json:"displayName"`
+       LlmPrice
+       Source string `json:"source"`
+       // Overridden is true where a stored row is standing in front of a 
built-in
+       // entry, which is what "Reset" on the page has to offer.
+       Overridden bool `json:"overridden"`
+}
+
+func (entry *LlmPriceEntry) price() LlmPrice {
+       return LlmPrice{
+               Input:        entry.Input,
+               Output:       entry.Output,
+               CacheWrite:   entry.CacheWrite,
+               CacheRead:    entry.CacheRead,
+               CacheWrite1h: entry.CacheWrite1h,
+       }
+}
+
+// normalizeLlmPriceModel spells a key the way the matcher reads it: lowercase,
+// trimmed, and without the provider prefix or the tag some catalogues append,
+// so "anthropic/claude-opus-5:beta" and "Claude-Opus-5" are one entry.
+func normalizeLlmPriceModel(model string) string {
+       name := strings.ToLower(strings.TrimSpace(model))
+       if slash := strings.LastIndex(name, "/"); slash != -1 {
+               name = name[slash+1:]
+       }
+       if colon := strings.Index(name, ":"); colon != -1 {
+               name = name[:colon]
+       }
+       return strings.TrimSpace(strings.ReplaceAll(name, "@", "-"))
+}
+
+// GetLlmPriceEntries lists the stored overrides, newest key order aside: the
+// page sorts them, this only has to be stable.
+func GetLlmPriceEntries() ([]*LlmPriceEntry, error) {
+       entries := []*LlmPriceEntry{}
+       if ormer == nil {
+               return entries, nil
+       }
+       err := ormer.Engine.Asc("model").Find(&entries)
+       return entries, err
+}
+
+// SetLlmPriceEntry writes one override and reloads the table, so a price
+// changed on the page costs the next request rather than the next restart.
+func SetLlmPriceEntry(entry *LlmPriceEntry) error {
+       entry.Model = normalizeLlmPriceModel(entry.Model)
+       if entry.Model == "" {
+               return errors.New("a price needs a model name")
+       }
+       if entry.Input < 0 || entry.Output < 0 || entry.CacheWrite < 0 ||
+               entry.CacheRead < 0 || entry.CacheWrite1h < 0 {
+               return errors.New("a price cannot be negative")
+       }
+       if entry.Source == "" {
+               entry.Source = LlmPriceSourceManual
+       }
+       entry.UpdatedTime = util.GetCurrentTime()
+
+       if err := upsertLlmPriceEntry(entry); err != nil {
+               return err
+       }
+       ReloadLlmPrices()
+       return nil
+}
+
+func upsertLlmPriceEntry(entry *LlmPriceEntry) error {
+       affected, err := ormer.Engine.ID(entry.Model).AllCols().Update(entry)
+       if err != nil {
+               return err
+       }
+       if affected > 0 {
+               return nil
+       }
+       _, err = ormer.Engine.Insert(entry)
+       return err
+}
+
+// DeleteLlmPriceEntry drops an override, which puts the built-in price for 
that
+// model back in effect where there is one.
+func DeleteLlmPriceEntry(model string) error {
+       if _, err := 
ormer.Engine.ID(normalizeLlmPriceModel(model)).Delete(&LlmPriceEntry{}); err != 
nil {
+               return err
+       }
+       ReloadLlmPrices()
+       return nil
+}
+
+// PutLlmPriceEntries writes a batch under one source, leaving alone any model
+// whose stored row was written by hand: a sync refreshes list prices, it does
+// not undo a correction somebody made on purpose. It answers with the models 
it
+// wrote and the ones it left.
+func PutLlmPriceEntries(entries []*LlmPriceEntry, source string) (written 
[]string, skipped []string, err error) {
+       stored, err := GetLlmPriceEntries()
+       if err != nil {
+               return nil, nil, err
+       }
+       bySource := map[string]string{}
+       for _, entry := range stored {
+               bySource[entry.Model] = entry.Source
+       }
+
+       written, skipped = []string{}, []string{}
+       for _, entry := range entries {
+               entry.Model = normalizeLlmPriceModel(entry.Model)
+               if entry.Model == "" {
+                       continue
+               }
+               if existing, found := bySource[entry.Model]; found && existing 
== LlmPriceSourceManual {
+                       skipped = append(skipped, entry.Model)
+                       continue
+               }
+               entry.Source = source
+               entry.UpdatedTime = util.GetCurrentTime()
+               if err := upsertLlmPriceEntry(entry); err != nil {
+                       return nil, nil, err
+               }
+               written = append(written, entry.Model)
+       }
+
+       sort.Strings(written)
+       sort.Strings(skipped)
+       ReloadLlmPrices()
+       return written, skipped, nil
+}
diff --git a/object/llm_pricing.go b/object/llm_pricing.go
index 1ed9cf2..b8aa59b 100644
--- a/object/llm_pricing.go
+++ b/object/llm_pricing.go
@@ -105,10 +105,15 @@ var (
        pricingLoaded bool
        pricingKeys   []string
        llmPrices     map[string]LlmPrice
+       // pricingOrigins says which layer each key in effect came from, which 
the
+       // Pricing page shows and "Reset" reads to know what it would fall back 
to.
+       pricingOrigins map[string]string
+       pricingNames   map[string]string
 )
 
-// ReloadLlmPrices re-reads the override file, so changing "llmPricingFile" on
-// the Settings page costs the next request rather than the next restart.
+// ReloadLlmPrices re-reads the override file and the stored overrides, so a
+// price edited on the Pricing page, or "llmPricingFile" changed on the 
Settings
+// page, costs the next request rather than the next restart.
 func ReloadLlmPrices() {
        pricingMutex.Lock()
        defer pricingMutex.Unlock()
@@ -131,14 +136,23 @@ func ensureLlmPrices() {
        }
 }
 
-// loadLlmPrices merges the override file over the built-in table, longest key
-// first so a specific model wins over the family it belongs to. It rebuilds 
the
-// table from the built-in one, so a price dropped from the file is dropped 
here
-// too. The caller holds the write lock.
+// loadLlmPrices merges the layers over the built-in table, longest key first 
so
+// a specific model wins over the family it belongs to. It rebuilds the table
+// from the built-in one, so a price dropped from a layer is dropped here too.
+// The caller holds the write lock.
+//
+// The layers, in the order each overrides the one before it: the built-in list
+// prices, the JSON file named by "llmPricingFile", and the rows stored by the
+// Pricing page and by a models.dev sync. The stored rows are last because they
+// are the only layer somebody edited from inside Gateway, and an edit there 
has
+// to be the one that takes effect.
 func loadLlmPrices() {
        prices := make(map[string]LlmPrice, len(builtInLlmPrices))
+       origins := make(map[string]string, len(builtInLlmPrices))
+       names := map[string]string{}
        for model, price := range builtInLlmPrices {
                prices[model] = price
+               origins[model] = LlmPriceSourceBuiltIn
        }
 
        path := conf.GetLlmPricingFile()
@@ -153,12 +167,28 @@ func loadLlmPrices() {
                                beego.Error("LLM pricing file is not valid 
JSON:", err)
                        } else {
                                for model, price := range overrides {
-                                       prices[strings.ToLower(model)] = price
+                                       key := strings.ToLower(model)
+                                       prices[key] = price
+                                       origins[key] = LlmPriceSourceFile
                                }
                        }
                }
        }
 
+       // A database that is not open yet leaves the stored layer out rather 
than
+       // failing the lookup: the built-in prices are still better than none.
+       entries, err := GetLlmPriceEntries()
+       if err != nil {
+               beego.Error("stored LLM prices could not be read:", err)
+       }
+       for _, entry := range entries {
+               prices[entry.Model] = entry.price()
+               origins[entry.Model] = entry.Source
+               if entry.DisplayName != "" {
+                       names[entry.Model] = entry.DisplayName
+               }
+       }
+
        keys := make([]string, 0, len(prices))
        for key := range prices {
                keys = append(keys, key)
@@ -166,6 +196,32 @@ func loadLlmPrices() {
        sort.Slice(keys, func(i, j int) bool { return len(keys[i]) > 
len(keys[j]) })
 
        llmPrices, pricingKeys, pricingLoaded = prices, keys, true
+       pricingOrigins, pricingNames = origins, names
+}
+
+// GetLlmPriceViews is every price in effect, in the order the page lists them:
+// by model name, because a price table is looked up by name rather than read
+// top to bottom.
+func GetLlmPriceViews() []LlmPriceView {
+       ensureLlmPrices()
+
+       pricingMutex.RLock()
+       defer pricingMutex.RUnlock()
+
+       views := make([]LlmPriceView, 0, len(llmPrices))
+       for model, price := range llmPrices {
+               _, builtIn := builtInLlmPrices[model]
+               source := pricingOrigins[model]
+               views = append(views, LlmPriceView{
+                       Model:       model,
+                       DisplayName: pricingNames[model],
+                       LlmPrice:    price,
+                       Source:      source,
+                       Overridden:  builtIn && source != LlmPriceSourceBuiltIn,
+               })
+       }
+       sort.Slice(views, func(i, j int) bool { return views[i].Model < 
views[j].Model })
+       return views
 }
 
 // GetLlmPrice matches a model name in any of the shapes it arrives in, e.g.
diff --git a/object/llm_record.go b/object/llm_record.go
index e1e843b..f88219f 100644
--- a/object/llm_record.go
+++ b/object/llm_record.go
@@ -592,6 +592,23 @@ func GetLlmRecordStatus() (*LlmRecordStatus, error) {
        }, nil
 }
 
+// GetSeenLlmModels is every model name the relayed records mention. It is what
+// a pricing sync is pointed at: the catalogue holds thousands of models and
+// only the ones this machine actually ran are worth a row in a table every
+// request is matched against.
+func GetSeenLlmModels() ([]string, error) {
+       models := []string{}
+       if ormer == nil {
+               return models, nil
+       }
+       err := ormer.Engine.Table("llm_record").
+               Distinct("model").
+               Where("model <> ''").
+               Asc("model").
+               Find(&models)
+       return models, err
+}
+
 // GetLlmRecordStats totals the same records GetLlmRecords lists.
 func GetLlmRecordStats(filter LlmRecordFilter, topModels int) 
(*LlmRecordStats, error) {
        countSession := llmRecordSession(filter)
diff --git a/object/models_dev.go b/object/models_dev.go
new file mode 100644
index 0000000..6a670b9
--- /dev/null
+++ b/object/models_dev.go
@@ -0,0 +1,418 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package object
+
+import (
+       "encoding/json"
+       "fmt"
+       "io"
+       "net/http"
+       "sort"
+       "strings"
+       "sync"
+       "time"
+
+       "github.com/apache/casbin-gateway/proxy"
+       "github.com/apache/casbin-gateway/util"
+)
+
+// models.dev publishes one JSON document of every model it knows, with list
+// prices already in US dollars per million tokens - the same unit LlmPrice is
+// in, so nothing is converted on the way in.
+const modelsDevUrl = "https://models.dev/api.json";
+
+const (
+       // The document is a few megabytes, so it is fetched with a timeout that
+       // tolerates a slow link and cached rather than read once per search.
+       modelsDevTimeout   = 60 * time.Second
+       modelsDevCacheLife = time.Hour
+       // A catalogue this large is never sent to the browser whole; a search
+       // answers with a page of it.
+       modelsDevSearchLimit = 200
+       modelsDevMaxBytes    = 32 << 20
+)
+
+// ModelsDevModel is one model of the catalogue, priced at what most of the
+// providers listing it agree that it costs - see fetchModelsDev.
+type ModelsDevModel struct {
+       Model       string `json:"model"`
+       DisplayName string `json:"displayName"`
+       ReleaseDate string `json:"releaseDate"`
+       // Providers is how many of them listed this model, which is what says 
how
+       // much agreement is behind the rates.
+       Providers  int     `json:"providers"`
+       Input      float64 `json:"input"`
+       Output     float64 `json:"output"`
+       CacheRead  float64 `json:"cacheRead"`
+       CacheWrite float64 `json:"cacheWrite"`
+}
+
+// ModelsDevSync is what one sync did, in the terms the page reports it: which
+// models were priced, which were left alone, and which the catalogue does not
+// know. Missing is the useful half - it names the models still costing 
nothing.
+type ModelsDevSync struct {
+       Catalogue  int      `json:"catalogue"`
+       Considered []string `json:"considered"`
+       Updated    []string `json:"updated"`
+       Skipped    []string `json:"skipped"`
+       Missing    []string `json:"missing"`
+       SyncedTime string   `json:"syncedTime"`
+}
+
+type modelsDevCost struct {
+       Input      *float64 `json:"input"`
+       Output     *float64 `json:"output"`
+       CacheRead  *float64 `json:"cache_read"`
+       CacheWrite *float64 `json:"cache_write"`
+}
+
+type modelsDevRawModel struct {
+       Id          string         `json:"id"`
+       Name        string         `json:"name"`
+       ReleaseDate string         `json:"release_date"`
+       Status      string         `json:"status"`
+       Cost        *modelsDevCost `json:"cost"`
+       Modalities  struct {
+               Output []string `json:"output"`
+       } `json:"modalities"`
+}
+
+type modelsDevRawProvider struct {
+       Name   string                       `json:"name"`
+       Models map[string]modelsDevRawModel `json:"models"`
+}
+
+var (
+       modelsDevMutex   sync.Mutex
+       modelsDevCache   []ModelsDevModel
+       modelsDevFetched time.Time
+)
+
+// GetModelsDevCatalogue is the catalogue, fetched at most once an hour. force
+// asks for it again regardless, which is what the Refresh button does.
+func GetModelsDevCatalogue(force bool) ([]ModelsDevModel, error) {
+       modelsDevMutex.Lock()
+       defer modelsDevMutex.Unlock()
+
+       if !force && modelsDevCache != nil && time.Since(modelsDevFetched) < 
modelsDevCacheLife {
+               return modelsDevCache, nil
+       }
+
+       models, err := fetchModelsDev()
+       if err != nil {
+               // A stale catalogue answers better than nothing when the 
network is
+               // down, and the caller is told neither way that it is stale: 
the sync
+               // result carries the time it was read.
+               if modelsDevCache != nil {
+                       return modelsDevCache, nil
+               }
+               return nil, err
+       }
+
+       modelsDevCache, modelsDevFetched = models, time.Now()
+       return models, nil
+}
+
+func fetchModelsDev() ([]ModelsDevModel, error) {
+       client := &http.Client{Timeout: modelsDevTimeout, Transport: 
proxy.Transport()}
+       resp, err := client.Get(modelsDevUrl)
+       if err != nil {
+               return nil, fmt.Errorf("models.dev could not be reached: %s", 
err.Error())
+       }
+       defer resp.Body.Close()
+
+       if resp.StatusCode != http.StatusOK {
+               return nil, fmt.Errorf("models.dev answered %s", resp.Status)
+       }
+       body, err := io.ReadAll(io.LimitReader(resp.Body, modelsDevMaxBytes))
+       if err != nil {
+               return nil, fmt.Errorf("models.dev could not be read: %s", 
err.Error())
+       }
+
+       catalogue := map[string]modelsDevRawProvider{}
+       if err := json.Unmarshal(body, &catalogue); err != nil {
+               return nil, fmt.Errorf("models.dev did not answer with the 
catalogue: %s", err.Error())
+       }
+
+       // One model is listed by every provider reselling it - thirty of them 
for a
+       // current Claude - and they do not all fill the same fields in. Rather 
than
+       // picking one listing and inheriting whatever it left out, each rate 
is the
+       // one most of them agree on, counting a missing field as "this model 
has no
+       // such charge". That is what reproduces the vendors' own published 
tables:
+       // most listings of an OpenAI model carry no cache-write rate because 
OpenAI
+       // does not charge for the write, while most listings of a Claude one 
do.
+       groups := map[string][]modelsDevRawModel{}
+       for _, provider := range catalogue {
+               for modelId, raw := range provider.Models {
+                       if !pricesText(modelId, raw) {
+                               continue
+                       }
+                       name := normalizeLlmPriceModel(modelId)
+                       if name == "" {
+                               continue
+                       }
+                       if raw.Name == "" {
+                               raw.Name = modelId
+                       }
+                       groups[name] = append(groups[name], raw)
+               }
+       }
+
+       models := make([]ModelsDevModel, 0, len(groups))
+       for name, listings := range groups {
+               input := agreedRate(listings, func(cost *modelsDevCost) 
*float64 { return cost.Input })
+               output := agreedRate(listings, func(cost *modelsDevCost) 
*float64 { return cost.Output })
+               // Both rates at zero is a listing nobody put a price on rather 
than a
+               // model that is free, and costing requests at nothing while 
calling
+               // them priced is worse than leaving them uncosted.
+               if input <= 0 && output <= 0 {
+                       continue
+               }
+               models = append(models, ModelsDevModel{
+                       Model:       name,
+                       DisplayName: agreedText(listings, func(raw 
modelsDevRawModel) string { return raw.Name }),
+                       ReleaseDate: agreedText(listings, func(raw 
modelsDevRawModel) string { return raw.ReleaseDate }),
+                       Providers:   len(listings),
+                       Input:       input,
+                       Output:      output,
+                       CacheRead:   agreedRate(listings, func(cost 
*modelsDevCost) *float64 { return cost.CacheRead }),
+                       CacheWrite:  agreedRate(listings, func(cost 
*modelsDevCost) *float64 { return cost.CacheWrite }),
+               })
+       }
+
+       // Newest first, so a search for a family shows the current generation
+       // before the ones it replaced.
+       sort.Slice(models, func(i, j int) bool {
+               if models[i].ReleaseDate != models[j].ReleaseDate {
+                       return models[i].ReleaseDate > models[j].ReleaseDate
+               }
+               return models[i].Model < models[j].Model
+       })
+       return models, nil
+}
+
+// agreedRate is the rate most of the listings carry, an absent one counting as
+// zero. A tie takes the lower rate, which is the half of a disagreement that
+// cannot overstate what somebody is being told they spent.
+func agreedRate(listings []modelsDevRawModel, read func(*modelsDevCost) 
*float64) float64 {
+       counts := map[float64]int{}
+       for _, raw := range listings {
+               rate := 0.0
+               if raw.Cost != nil {
+                       rate = amount(read(raw.Cost))
+               }
+               counts[rate]++
+       }
+
+       best, bestCount := 0.0, 0
+       for rate, count := range counts {
+               if count > bestCount || (count == bestCount && rate < best) {
+                       best, bestCount = rate, count
+               }
+       }
+       return best
+}
+
+// agreedText is agreedRate for a name or a date: the spelling most of the
+// listings use, ignoring the ones that left it empty.
+func agreedText(listings []modelsDevRawModel, read func(modelsDevRawModel) 
string) string {
+       counts := map[string]int{}
+       for _, raw := range listings {
+               if value := strings.TrimSpace(read(raw)); value != "" {
+                       counts[value]++
+               }
+       }
+
+       best, bestCount := "", 0
+       for value, count := range counts {
+               if count > bestCount || (count == bestCount && value < best) {
+                       best, bestCount = value, count
+               }
+       }
+       return best
+}
+
+// pricesText keeps the entries a token price means anything for: a model that
+// is retired, or that answers in audio or pictures, is not one this gateway
+// costs by the token.
+func pricesText(modelId string, raw modelsDevRawModel) bool {
+       if raw.Cost == nil || raw.Cost.Input == nil || raw.Cost.Output == nil {
+               return false
+       }
+       if strings.EqualFold(raw.Status, "deprecated") {
+               return false
+       }
+       if len(raw.Modalities.Output) > 0 {
+               text := false
+               for _, modality := range raw.Modalities.Output {
+                       switch strings.ToLower(modality) {
+                       case "text":
+                               text = true
+                       case "audio", "image", "video":
+                               return false
+                       }
+               }
+               if !text {
+                       return false
+               }
+       }
+
+       searchable := strings.ToLower(modelId + " " + raw.Name)
+       for _, marker := range []string{
+               "embedding", "moderation", "realtime", "transcribe", "tts", 
"audio", "image", "video",
+       } {
+               if strings.Contains(searchable, marker) {
+                       return false
+               }
+       }
+       return true
+}
+
+func amount(value *float64) float64 {
+       if value == nil || *value < 0 {
+               return 0
+       }
+       return *value
+}
+
+// SearchModelsDev is one page of the catalogue matching a query, for the 
dialog
+// that adds a price for a model this machine has not run yet.
+func SearchModelsDev(query string, force bool) ([]ModelsDevModel, error) {
+       models, err := GetModelsDevCatalogue(force)
+       if err != nil {
+               return nil, err
+       }
+
+       needle := strings.ToLower(strings.TrimSpace(query))
+       found := []ModelsDevModel{}
+       for _, model := range models {
+               if needle != "" &&
+                       !strings.Contains(model.Model, needle) &&
+                       !strings.Contains(strings.ToLower(model.DisplayName), 
needle) {
+                       continue
+               }
+               found = append(found, model)
+               if len(found) >= modelsDevSearchLimit {
+                       break
+               }
+       }
+       return found, nil
+}
+
+// matchModelsDev finds the catalogue entry that prices a model name, by the
+// same rule GetLlmPrice matches by: the longest catalogue id contained in the
+// name. That is what makes one entry cover "claude-opus-5", the dated
+// "claude-opus-5-20260214" and the vendor-prefixed 
"us.anthropic.claude-opus-5".
+func matchModelsDev(models []ModelsDevModel, name string) (ModelsDevModel, 
bool) {
+       needle := strings.ToLower(strings.TrimSpace(name))
+       best, found := ModelsDevModel{}, false
+       for _, model := range models {
+               if !strings.Contains(needle, model.Model) {
+                       continue
+               }
+               if !found || len(model.Model) > len(best.Model) {
+                       best, found = model, true
+               }
+       }
+       return best, found
+}
+
+// longCacheRate is what a cache written to live an hour costs. models.dev does
+// not publish one, and a stored row replaces its built-in entry whole, so
+// leaving it at zero would quietly bill every long write at the five-minute
+// rate. The built-in table answers for the models it knows; for the rest the
+// rate is read off the shape of the pricing itself.
+//
+// Anthropic is the only vendor that sells the longer life, and it prices the
+// pair against base input exactly: a five-minute write at 1.25x and an hour at
+// 2x. So a model charging 1.25x for its write is on that scheme and its hour
+// rate is 2x, and a model that charges nothing to write - everyone else - has
+// no such option to price.
+func longCacheRate(model ModelsDevModel) float64 {
+       if rate := builtInLlmPrices[model.Model].CacheWrite1h; rate > 0 {
+               return rate
+       }
+       if model.Input <= 0 || model.CacheWrite <= 0 {
+               return 0
+       }
+       if ratio := model.CacheWrite / model.Input; ratio < 1.245 || ratio > 
1.255 {
+               return 0
+       }
+       return model.Input * 2
+}
+
+// SyncModelsDevPrices prices the models this machine has actually run. The
+// catalogue holds thousands of them and all but a few would be dead weight in 
a
+// table every request is matched against, so the set to price is passed in
+// rather than imported wholesale.
+//
+// A model somebody priced by hand is left as it is - see PutLlmPriceEntries.
+func SyncModelsDevPrices(names []string, force bool) (*ModelsDevSync, error) {
+       catalogue, err := GetModelsDevCatalogue(force)
+       if err != nil {
+               return nil, err
+       }
+
+       result := &ModelsDevSync{
+               Catalogue:  len(catalogue),
+               Considered: []string{},
+               Updated:    []string{},
+               Skipped:    []string{},
+               Missing:    []string{},
+               SyncedTime: util.GetCurrentTime(),
+       }
+
+       // One catalogue entry can price several of the names seen, so the 
entries
+       // are collected by key first and each written once.
+       entries := map[string]*LlmPriceEntry{}
+       seen := map[string]bool{}
+       for _, name := range names {
+               key := strings.ToLower(strings.TrimSpace(name))
+               if key == "" || key == unknownModel || seen[key] {
+                       continue
+               }
+               seen[key] = true
+               result.Considered = append(result.Considered, key)
+
+               match, ok := matchModelsDev(catalogue, key)
+               if !ok {
+                       result.Missing = append(result.Missing, key)
+                       continue
+               }
+               entries[match.Model] = &LlmPriceEntry{
+                       Model:        match.Model,
+                       DisplayName:  match.DisplayName,
+                       Input:        match.Input,
+                       Output:       match.Output,
+                       CacheWrite:   match.CacheWrite,
+                       CacheRead:    match.CacheRead,
+                       CacheWrite1h: longCacheRate(match),
+               }
+       }
+
+       batch := make([]*LlmPriceEntry, 0, len(entries))
+       for _, entry := range entries {
+               batch = append(batch, entry)
+       }
+       written, skipped, err := PutLlmPriceEntries(batch, 
LlmPriceSourceModelsDev)
+       if err != nil {
+               return nil, err
+       }
+
+       sort.Strings(result.Considered)
+       sort.Strings(result.Missing)
+       result.Updated, result.Skipped = written, skipped
+       return result, nil
+}
diff --git a/object/ormer.go b/object/ormer.go
index 633fbd1..c44a314 100644
--- a/object/ormer.go
+++ b/object/ormer.go
@@ -273,4 +273,11 @@ func (a *Ormer) createTable() {
        if err != nil {
                panic(err)
        }
+
+       // Token prices edited on the Pricing page or written by a models.dev 
sync,
+       // which override the built-in list prices.
+       err = a.Engine.Sync2(new(LlmPriceEntry))
+       if err != nil {
+               panic(err)
+       }
 }
diff --git a/routers/router.go b/routers/router.go
index 9212405..f13bcb5 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -95,6 +95,12 @@ func initAPI() {
        beego.Router("/api/delete-llm-record", &controllers.ApiController{}, 
"POST:DeleteLlmRecord")
        beego.Router("/api/clear-llm-records", &controllers.ApiController{}, 
"POST:ClearLlmRecords")
 
+       beego.Router("/api/get-llm-prices", &controllers.ApiController{}, 
"GET:GetLlmPrices")
+       beego.Router("/api/update-llm-price", &controllers.ApiController{}, 
"POST:UpdateLlmPrice")
+       beego.Router("/api/delete-llm-price", &controllers.ApiController{}, 
"POST:DeleteLlmPrice")
+       beego.Router("/api/search-models-dev-models", 
&controllers.ApiController{}, "GET:SearchModelsDevModels")
+       beego.Router("/api/sync-models-dev-prices", 
&controllers.ApiController{}, "POST:SyncModelsDevPrices")
+
        // The LLM gateway, in every wire format a client speaks. The agent 
routes
        // carry the endpoint shapes those clients append to one base URL.
        beego.Router("/v1/chat/completions", &controllers.ApiController{}, 
"POST:ChatCompletions")
diff --git a/web/src/App.tsx b/web/src/App.tsx
index dbee8e3..1cc7f6b 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -39,6 +39,7 @@ import ProviderEditPage from "@/pages/ProviderEditPage";
 import ProviderListPage from "@/pages/ProviderListPage";
 import LlmRecordsPage from "@/pages/LlmRecordsPage";
 import UsagePage from "@/pages/UsagePage";
+import PricingPage from "@/pages/PricingPage";
 import SettingPage from "@/pages/SettingPage";
 import SigninPage from "@/pages/SigninPage";
 import type {Account, ThemeAlgorithm} from "@/types";
@@ -286,6 +287,7 @@ export default function App() {
                   element={requireSignin(user => <LlmRecordsPage 
account={user} />)}
                 />
                 <Route path="/usage" element={requireSignin(user => <UsagePage 
account={user} />)} />
+                <Route path="/pricing" element={requireSignin(user => 
<PricingPage account={user} />)} />
               </Routes>
             </React.Suspense>
           </main>
diff --git a/web/src/backend/LlmPriceBackend.ts 
b/web/src/backend/LlmPriceBackend.ts
new file mode 100644
index 0000000..5a93ffd
--- /dev/null
+++ b/web/src/backend/LlmPriceBackend.ts
@@ -0,0 +1,49 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {query, request} from "@/backend/request";
+import type {LlmPriceEntry, LlmPriceView, ModelsDevModel, ModelsDevSync} from 
"@/types";
+
+/** Every price in effect, built-in ones included, each with the layer it came 
from. */
+export function getLlmPrices() {
+  return request<LlmPriceView[]>("/api/get-llm-prices");
+}
+
+/** Stores one price, which from then on overrides the built-in table for that 
model. */
+export function updateLlmPrice(entry: Partial<LlmPriceEntry>) {
+  return request<LlmPriceEntry>("/api/update-llm-price", "POST", entry);
+}
+
+/** Drops a stored price, putting the built-in one back in effect where there 
is one. */
+export function deleteLlmPrice(model: string) {
+  return request<boolean>(`/api/delete-llm-price${query({model: model})}`, 
"POST");
+}
+
+/**
+ * One page of the models.dev catalogue. The whole thing is a few megabytes, so
+ * the search happens on the server and the browser only ever sees the matches.
+ */
+export function searchModelsDevModels(q: string, refresh = false) {
+  return request<ModelsDevModel[]>(
+    `/api/search-models-dev-models${query({q: q, refresh: refresh ? "true" : 
""})}`,
+  );
+}
+
+/** Prices every model this machine has been seen running, from models.dev. */
+export function syncModelsDevPrices(refresh = true) {
+  return request<ModelsDevSync>(
+    `/api/sync-models-dev-prices${query({refresh: refresh ? "true" : ""})}`,
+    "POST",
+  );
+}
diff --git a/web/src/components/usage/models-dev-picker.tsx 
b/web/src/components/usage/models-dev-picker.tsx
new file mode 100644
index 0000000..a3a1ec9
--- /dev/null
+++ b/web/src/components/usage/models-dev-picker.tsx
@@ -0,0 +1,152 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {Search} from "lucide-react";
+import i18next from "i18next";
+
+import * as LlmPriceBackend from "@/backend/LlmPriceBackend";
+import {Badge} from "@/components/ui/badge";
+import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} 
from "@/components/ui/dialog";
+import {Input} from "@/components/ui/input";
+import {Loading} from "@/components/shared/loading";
+import {MessageAlert} from "@/components/ui/alert";
+import {EmptyState} from "@/components/shared/empty-state";
+import {formatRate} from "@/lib/usage";
+import type {ModelsDevModel} from "@/types";
+
+/** Long enough that typing a model name is one request rather than eight. */
+const SEARCH_DELAY = 300;
+
+/**
+ * Picks a model out of the models.dev catalogue and hands back its rates, so a
+ * price is copied from the catalogue rather than typed in off a vendor's page.
+ * The search runs on the server: the catalogue is a few megabytes and only the
+ * matches are worth sending here.
+ */
+export function ModelsDevPicker({
+  open,
+  onOpenChange,
+  onPick,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  onPick: (model: ModelsDevModel) => void;
+}) {
+  const [draft, setDraft] = React.useState("");
+  const [models, setModels] = React.useState<ModelsDevModel[]>([]);
+  const [loading, setLoading] = React.useState(false);
+  const [error, setError] = React.useState("");
+
+  React.useEffect(() => {
+    if (!open) {
+      return undefined;
+    }
+
+    let live = true;
+    setLoading(true);
+    const timer = setTimeout(() => {
+      LlmPriceBackend.searchModelsDevModels(draft)
+        .then(res => {
+          if (!live) {
+            return;
+          }
+          if (res.status === "ok") {
+            setModels(res.data ?? []);
+            setError("");
+          } else {
+            setError(res.msg || i18next.t("general:Failed to get data"));
+          }
+        })
+        .catch(failure => live && setError(failure.message || String(failure)))
+        .then(() => live && setLoading(false));
+    }, SEARCH_DELAY);
+
+    return () => {
+      live = false;
+      clearTimeout(timer);
+    };
+  }, [open, draft]);
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-2xl">
+        <DialogHeader>
+          <DialogTitle>{i18next.t("usage:Import from 
models.dev")}</DialogTitle>
+          <DialogDescription>{i18next.t("usage:Import from models.dev 
description")}</DialogDescription>
+        </DialogHeader>
+
+        <div className="relative">
+          <Search className="text-muted-foreground absolute top-1/2 left-3 
size-4 -translate-y-1/2" />
+          <Input
+            autoFocus
+            className="pl-9"
+            placeholder={i18next.t("usage:Search the catalogue")}
+            value={draft}
+            onChange={event => setDraft(event.target.value)}
+          />
+        </div>
+
+        {error ? <MessageAlert title={error} /> : null}
+
+        <div className="max-h-[24rem] overflow-y-auto">
+          {loading ? (
+            <Loading />
+          ) : models.length === 0 ? (
+            <EmptyState icon={Search} title={i18next.t("usage:No model 
matches")} />
+          ) : (
+            <div className="flex flex-col gap-1">
+              {models.map(model => (
+                <button
+                  key={model.model}
+                  type="button"
+                  onClick={() => {
+                    onPick(model);
+                    onOpenChange(false);
+                  }}
+                  className="hover:bg-accent flex flex-col gap-1 rounded-md 
border p-2.5 text-left transition-colors"
+                >
+                  <div className="flex min-w-0 flex-wrap items-center gap-2">
+                    <span className="truncate font-mono 
text-xs">{model.model}</span>
+                    <span className="text-muted-foreground truncate 
text-xs">{model.displayName}</span>
+                    {model.releaseDate ? <Badge 
variant="muted">{model.releaseDate}</Badge> : null}
+                  </div>
+                  <div className="text-muted-foreground flex flex-wrap gap-3 
text-xs tabular-nums">
+                    <span>
+                      {i18next.t("usage:Input")} {formatRate(model.input)}
+                    </span>
+                    <span>
+                      {i18next.t("llm:Output")} {formatRate(model.output)}
+                    </span>
+                    <span>
+                      {i18next.t("llm:Cache write")} 
{formatRate(model.cacheWrite)}
+                    </span>
+                    <span>
+                      {i18next.t("llm:Cache read")} 
{formatRate(model.cacheRead)}
+                    </span>
+                    <span className="ml-auto">
+                      {i18next
+                        .t(model.providers === 1 ? "usage:One provider lists 
it" : "usage:{count} providers list it")
+                        .replace("{count}", String(model.providers))}
+                    </span>
+                  </div>
+                </button>
+              ))}
+            </div>
+          )}
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+}
diff --git a/web/src/components/usage/price-edit-dialog.tsx 
b/web/src/components/usage/price-edit-dialog.tsx
new file mode 100644
index 0000000..28ac3e6
--- /dev/null
+++ b/web/src/components/usage/price-edit-dialog.tsx
@@ -0,0 +1,201 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {Download} from "lucide-react";
+import i18next from "i18next";
+
+import {FormDialog} from "@/components/shared/form-dialog";
+import {ModelsDevPicker} from "@/components/usage/models-dev-picker";
+import {Button} from "@/components/ui/button";
+import {Input} from "@/components/ui/input";
+import {Label} from "@/components/ui/label";
+import type {LlmPriceEntry, LlmPriceView, ModelsDevModel} from "@/types";
+
+/** The rates one price is made of, in the order the table shows them. */
+type RateField = "input" | "output" | "cacheWrite" | "cacheRead" | 
"cacheWrite1h";
+
+const RATE_FIELDS: {field: RateField; label: string; hint?: string}[] = [
+  {field: "input", label: "usage:Input"},
+  {field: "output", label: "llm:Output"},
+  {field: "cacheWrite", label: "llm:Cache write"},
+  {field: "cacheRead", label: "llm:Cache read"},
+  {field: "cacheWrite1h", label: "usage:Cache write 1h", hint: "usage:Cache 
write 1h hint"},
+];
+
+/** The form's own state. Rates are held as text so a half-typed "0." survives
+ *  the keystroke that would otherwise round it away. */
+interface PriceDraft {
+  model: string;
+  displayName: string;
+  rates: Record<RateField, string>;
+}
+
+const emptyRates: Record<RateField, string> = {
+  input: "0",
+  output: "0",
+  cacheWrite: "0",
+  cacheRead: "0",
+  cacheWrite1h: "0",
+};
+
+function draftOf(price: LlmPriceView | null): PriceDraft {
+  if (price === null) {
+    return {model: "", displayName: "", rates: {...emptyRates}};
+  }
+  return {
+    model: price.model,
+    displayName: price.displayName,
+    rates: {
+      input: String(price.input),
+      output: String(price.output),
+      cacheWrite: String(price.cacheWrite),
+      cacheRead: String(price.cacheRead),
+      cacheWrite1h: String(price.cacheWrite1h),
+    },
+  };
+}
+
+function rateOf(text: string) {
+  const parsed = Number.parseFloat(text);
+  return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
+}
+
+/**
+ * Edits what one model costs. A price saved here is a manual one from then on,
+ * which is what keeps it from being overwritten by the next models.dev sync.
+ */
+export function PriceEditDialog({
+  open,
+  onOpenChange,
+  price,
+  onSave,
+  saving,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  /** Null adds a price rather than editing one. */
+  price: LlmPriceView | null;
+  onSave: (entry: Partial<LlmPriceEntry>) => void;
+  saving?: boolean;
+}) {
+  const [draft, setDraft] = React.useState<PriceDraft>(() => draftOf(price));
+  const [picking, setPicking] = React.useState(false);
+
+  // The dialog is kept mounted between rows, so the draft is reset whenever it
+  // is opened on a different price rather than on first render only.
+  React.useEffect(() => {
+    if (open) {
+      setDraft(draftOf(price));
+    }
+  }, [open, price]);
+
+  const adding = price === null;
+
+  const setRate = (field: RateField, value: string) =>
+    setDraft(current => ({...current, rates: {...current.rates, [field]: 
value}}));
+
+  const fillFrom = (model: ModelsDevModel) =>
+    setDraft(current => ({
+      model: current.model.trim() === "" ? model.model : current.model,
+      displayName: model.displayName,
+      rates: {
+        ...current.rates,
+        input: String(model.input),
+        output: String(model.output),
+        cacheWrite: String(model.cacheWrite),
+        cacheRead: String(model.cacheRead),
+      },
+    }));
+
+  const submit = () =>
+    onSave({
+      model: draft.model,
+      displayName: draft.displayName,
+      input: rateOf(draft.rates.input),
+      output: rateOf(draft.rates.output),
+      cacheWrite: rateOf(draft.rates.cacheWrite),
+      cacheRead: rateOf(draft.rates.cacheRead),
+      cacheWrite1h: rateOf(draft.rates.cacheWrite1h),
+    });
+
+  return (
+    <>
+      <FormDialog
+        open={open}
+        onOpenChange={onOpenChange}
+        title={adding ? i18next.t("usage:Add a price") : i18next.t("usage:Edit 
price")}
+        description={i18next.t("usage:Price dialog description")}
+        onSubmit={submit}
+        submitting={saving}
+        submitDisabled={draft.model.trim() === ""}
+      >
+        <div className="flex flex-col gap-4">
+          <div className="grid gap-4 sm:grid-cols-2">
+            <div className="flex flex-col gap-2">
+              <Label htmlFor="price-model">{i18next.t("agent:Model")}</Label>
+              <Input
+                id="price-model"
+                // The key is what a request is matched against, so an existing
+                // one is not editable: changing it would leave the old row in
+                // place and quietly add a second.
+                disabled={!adding}
+                className="font-mono"
+                placeholder="claude-opus-5"
+                value={draft.model}
+                onChange={event => setDraft(current => ({...current, model: 
event.target.value}))}
+              />
+              <span className="text-muted-foreground 
text-xs">{i18next.t("usage:Model key hint")}</span>
+            </div>
+            <div className="flex flex-col gap-2">
+              <Label htmlFor="price-name">{i18next.t("general:Display 
name")}</Label>
+              <Input
+                id="price-name"
+                placeholder="Claude Opus 5"
+                value={draft.displayName}
+                onChange={event => setDraft(current => ({...current, 
displayName: event.target.value}))}
+              />
+              <Button type="button" variant="outline" size="sm" onClick={() => 
setPicking(true)}>
+                <Download />
+                {i18next.t("usage:Import from models.dev")}
+              </Button>
+            </div>
+          </div>
+
+          <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
+            {RATE_FIELDS.map(({field, label, hint}) => (
+              <div key={field} className="flex flex-col gap-2">
+                <Label htmlFor={`price-${field}`}>{i18next.t(label)}</Label>
+                <Input
+                  id={`price-${field}`}
+                  type="number"
+                  min={0}
+                  step="any"
+                  className="tabular-nums"
+                  value={draft.rates[field]}
+                  onChange={event => setRate(field, event.target.value)}
+                />
+                {hint ? <span className="text-muted-foreground 
text-xs">{i18next.t(hint)}</span> : null}
+              </div>
+            ))}
+          </div>
+
+          <span className="text-muted-foreground 
text-xs">{i18next.t("usage:Rates are per million tokens")}</span>
+        </div>
+      </FormDialog>
+
+      <ModelsDevPicker open={picking} onOpenChange={setPicking} 
onPick={fillFrom} />
+    </>
+  );
+}
diff --git a/web/src/components/usage/usage-hero.tsx 
b/web/src/components/usage/usage-hero.tsx
index 3d61a0a..6811356 100644
--- a/web/src/components/usage/usage-hero.tsx
+++ b/web/src/components/usage/usage-hero.tsx
@@ -13,6 +13,7 @@
 // limitations under the License.
 
 import * as React from "react";
+import {Link} from "react-router-dom";
 import {Activity, ArrowDownToLine, ArrowUpFromLine, CircleDollarSign, 
Database, Sparkles} from "lucide-react";
 import i18next from "i18next";
 
@@ -162,9 +163,13 @@ export function UsageHero({
         </div>
 
         {totals.unpriced > 0 ? (
-          <span className="text-muted-foreground text-xs">
+          // The count on its own leaves the reader stuck; the page that fixes
+          // it is one click away, so it is the click.
+          <Link to="/pricing" className="text-muted-foreground 
hover:text-foreground text-xs underline-offset-4 hover:underline">
             {i18next.t("llm:{count} records have no price").replace("{count}", 
totals.unpriced.toLocaleString())}
-          </span>
+            {" · "}
+            {i18next.t("usage:Set their prices")}
+          </Link>
         ) : null}
       </CardContent>
     </Card>
diff --git a/web/src/lib/usage.ts b/web/src/lib/usage.ts
index 59e402b..0863e9d 100644
--- a/web/src/lib/usage.ts
+++ b/web/src/lib/usage.ts
@@ -26,6 +26,18 @@ export function formatCost(cost: number) {
   return `$${cost.toFixed(2)}`;
 }
 
+/**
+ * A price, which is US dollars per million tokens. Written with as many digits
+ * as the rate actually has: a cache read can be a fraction of a cent and
+ * rounding it to two places would print every one of them as "$0.00".
+ */
+export function formatRate(rate: number) {
+  if (!Number.isFinite(rate) || rate <= 0) {
+    return "$0";
+  }
+  return `$${Number(rate.toFixed(6))}`;
+}
+
 export function formatTokens(tokens: number) {
   if (tokens < 1000) {
     return String(tokens);
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 7f144d8..59e361c 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -630,22 +630,56 @@
     "Sign-in description": "Casdoor is optional: leave the endpoint empty to 
sign in against Gateway's own user table."
   },
   "usage": {
+    "Add a price": "Add a price",
     "Agents description": "What the agents on this machine spent, read from 
the transcripts they write themselves. This is the only account of a request 
that never went through Gateway.",
+    "Built in": "Built in",
     "By agent": "By agent",
     "By model": "By model",
     "By provider": "By provider",
+    "Cache write 1h": "Cache write (1h)",
+    "Cache write 1h hint": "Only Anthropic sells a cache that lives an hour. 
Zero means every write is charged at the rate above.",
     "Cache writes are not reported by every protocol": "A provider serving the 
OpenAI protocol reports cache hits but never cache writes, so this can read as 
zero while the cache is being written.",
+    "Delete this price detail": "There is no built-in price for this model, so 
requests using it stop being costed until a price is set again.",
+    "Delete this price?": "Delete this price?",
+    "Edit price": "Edit price",
+    "Edited by hand": "Edited by hand",
     "Every agent": "Every agent",
     "Every {count}s": "Every {count}s",
+    "Import from models.dev": "Import from models.dev",
+    "Import from models.dev description": "Pick a model to copy its published 
rates into the form. models.dev lists the same model under every provider 
reselling it, so each rate here is the one most of them agree on.",
+    "Input": "Input",
     "Last {count} days": "Last {count} days",
+    "Left as edited": "Left as you edited them:",
+    "Model key hint": "Matched as a substring of the name a request arrives 
under, so \"claude-opus-5\" also prices \"claude-opus-5-20260214\".",
+    "Model pricing": "Model pricing",
+    "Model pricing description": "What a million tokens costs, which is what 
every figure on the Usage page is worked out from. A price stored here 
overrides the built-in list price for that model.",
     "No auto refresh": "No auto refresh",
+    "No model matches": "No model matches that search",
+    "No prices yet": "No prices are configured",
     "Nothing was spent in this window": "Nothing was spent in this window",
+    "One provider lists it": "One provider lists it",
+    "Price dialog description": "Rates are in US dollars per million tokens. A 
price saved here is yours from then on, and a models.dev sync will leave it 
alone.",
+    "Priced {updated} of {considered} models": "Priced {updated} of the 
{considered} models this machine has run",
+    "Prices are missing": "Some models have no price",
+    "Pricing file": "Pricing file",
+    "Put the built-in price back detail": "The stored price is dropped and the 
built-in list price for this model takes effect again. Records already costed 
keep the figures they were costed at.",
+    "Put the built-in price back?": "Put the built-in price back?",
+    "Rates are per million tokens": "All five rates are in US dollars per 
million tokens.",
+    "Read from a catalogue of {count} models": "Read from a models.dev 
catalogue of {count} models.",
     "Recording is off detail": "llmRecordMode is \"off\", so nothing relayed 
is being counted. The agents' own transcripts are counted either way.",
     "Relayed description": "What Gateway relayed on behalf of the agents. This 
is the only account that knows which provider answered, and whether it failed.",
+    "Search the catalogue": "Search by model name",
+    "Set their prices": "Set their prices",
+    "Source": "Source",
+    "Still unpriced": "Still unpriced:",
+    "Sync from models.dev": "Sync from models.dev",
     "Tokens spent": "Tokens spent",
     "Trend": "Trend",
+    "Updated": "Updated:",
     "Usage": "Usage",
     "What Gateway relayed": "What Gateway relayed",
-    "What the agents spent": "What the agents spent"
+    "What the agents spent": "What the agents spent",
+    "models.dev": "models.dev",
+    "{count} providers list it": "{count} providers list it"
   }
 }
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index e4ef2f2..b64c62f 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -630,22 +630,56 @@
     "Sign-in description": "Casdoor 是可选的:地址留空则使用 Gateway 自带的用户表登录。"
   },
   "usage": {
+    "Add a price": "新增价格",
     "Agents description": "这台机器上的 agent 花掉的量,读自它们自己写的会话记录。没有经过 Gateway 
的请求,只有这一份账。",
+    "Built in": "内置",
     "By agent": "按 Agent",
     "By model": "按模型",
     "By provider": "按 Provider",
+    "Cache write 1h": "缓存写入(1 小时)",
+    "Cache write 1h hint": "只有 Anthropic 卖 1 小时缓存。填 0 表示所有写入都按上面那档计价。",
     "Cache writes are not reported by every protocol": "走 OpenAI 协议的 provider 
只上报缓存命中,不上报缓存写入,所以这里可能显示为 0 而缓存其实在写。",
+    "Delete this price detail": "这个模型没有内置价格,删掉后用到它的请求将不再计入成本,直到重新设置价格。",
+    "Delete this price?": "删除这个价格?",
+    "Edit price": "编辑价格",
+    "Edited by hand": "手动修改",
     "Every agent": "全部 Agent",
     "Every {count}s": "每 {count} 秒",
+    "Import from models.dev": "从 models.dev 导入",
+    "Import from models.dev description": "选一个模型,把它公布的价格填进表单。models.dev 
会把同一个模型列在每一家转售它的厂商下,所以这里每一档取的是多数厂商一致的那个价。",
+    "Input": "输入",
     "Last {count} days": "最近 {count} 天",
+    "Left as edited": "保留你的修改:",
+    "Model key hint": "按子串匹配请求里的模型名,所以填 \"claude-opus-5\" 也会给 
\"claude-opus-5-20260214\" 计价。",
+    "Model pricing": "模型定价",
+    "Model pricing description": "每百万 token 
的价格,用量页上的每个金额都由它算出。这里存的价格会覆盖对应模型的内置价。",
     "No auto refresh": "不自动刷新",
+    "No model matches": "没有匹配的模型",
+    "No prices yet": "还没有配置任何价格",
     "Nothing was spent in this window": "这个时间范围内没有消耗",
+    "One provider lists it": "1 家收录",
+    "Price dialog description": "单位是美元每百万 token。这里保存的价格从此归你所有,models.dev 
同步不会覆盖它。",
+    "Priced {updated} of {considered} models": "这台机器跑过 {considered} 个模型,给其中 
{updated} 个定了价",
+    "Prices are missing": "有模型没有价格",
+    "Pricing file": "定价文件",
+    "Put the built-in price back detail": "删掉存储的价格,该模型重新按内置价计费。已经算过成本的记录保持原样。",
+    "Put the built-in price back?": "恢复内置价格?",
+    "Rates are per million tokens": "五档价格的单位都是美元每百万 token。",
+    "Read from a catalogue of {count} models": "读自 models.dev 的 {count} 
个模型目录。",
     "Recording is off detail": "llmRecordMode 是 \"off\",转发的请求没有被统计。Agent 
自己的会话记录不受影响,照常统计。",
     "Relayed description": "Gateway 代为转发的量。只有这一份账知道是哪个 provider 应答的,以及是否失败。",
+    "Search the catalogue": "按模型名搜索",
+    "Set their prices": "去设置价格",
+    "Source": "来源",
+    "Still unpriced": "仍未定价:",
+    "Sync from models.dev": "从 models.dev 同步",
     "Tokens spent": "消耗 Tokens",
     "Trend": "趋势",
+    "Updated": "已更新:",
     "Usage": "用量",
     "What Gateway relayed": "Gateway 转发的账",
-    "What the agents spent": "Agent 自己的账"
+    "What the agents spent": "Agent 自己的账",
+    "models.dev": "models.dev",
+    "{count} providers list it": "{count} 家收录"
   }
 }
diff --git a/web/src/nav.ts b/web/src/nav.ts
index 4211078..ae81b0b 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -16,6 +16,7 @@ import {
   Blocks,
   Bot,
   ChartColumn,
+  CircleDollarSign,
   FileSearch,
   Logs,
   MessageSquare,
@@ -76,6 +77,7 @@ export const navGroups: NavGroup[] = [
   {key: "/providers", label: "provider:Providers", icon: Plug, path: 
"/providers"},
   {key: "/llm-records", label: "llm:LLM Records", icon: Logs, path: 
"/llm-records", adminOnly: true},
   {key: "/usage", label: "usage:Usage", icon: ChartColumn, path: "/usage", 
adminOnly: true},
+  {key: "/pricing", label: "usage:Model pricing", icon: CircleDollarSign, 
path: "/pricing", adminOnly: true},
   {key: "/settings", label: "setting:Settings", icon: Settings, path: 
"/settings", adminOnly: true},
 ];
 
diff --git a/web/src/pages/PricingPage.tsx b/web/src/pages/PricingPage.tsx
new file mode 100644
index 0000000..c716be9
--- /dev/null
+++ b/web/src/pages/PricingPage.tsx
@@ -0,0 +1,300 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {CircleDollarSign, CloudDownload, Pencil, Plus, RotateCcw, Trash2} from 
"lucide-react";
+import i18next from "i18next";
+
+import * as LlmPriceBackend from "@/backend/LlmPriceBackend";
+import * as Setting from "@/Setting";
+import {PriceEditDialog} from "@/components/usage/price-edit-dialog";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {DataTable, type Column} from "@/components/shared/data-table";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {CodeText, UnauthorizedResult} from "@/components/shared/misc";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge, type BadgeVariant} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {formatRate} from "@/lib/usage";
+import type {Account, LlmPriceEntry, LlmPriceSource, LlmPriceView, 
ModelsDevSync} from "@/types";
+
+const SOURCE_TONE: Record<LlmPriceSource, BadgeVariant> = {
+  "built-in": "muted",
+  file: "info",
+  "models.dev": "success",
+  manual: "warning",
+};
+
+const SOURCE_LABEL: Record<LlmPriceSource, string> = {
+  "built-in": "usage:Built in",
+  file: "usage:Pricing file",
+  "models.dev": "usage:models.dev",
+  manual: "usage:Edited by hand",
+};
+
+/** What one sync did, in the order it is worth reading: what is still unpriced
+ *  first, because those are the models the Usage page cannot cost. */
+function SyncReport({result, onDismiss}: {result: ModelsDevSync; onDismiss: () 
=> void}) {
+  const line = (label: string, models: string[]) =>
+    models.length === 0 ? null : (
+      <div className="flex min-w-0 flex-wrap items-baseline gap-1.5">
+        <span className="shrink-0 text-xs 
font-medium">{i18next.t(label)}</span>
+        {models.map(model => (
+          <CodeText key={model}>{model}</CodeText>
+        ))}
+      </div>
+    );
+
+  return (
+    <MessageAlert
+      variant={result.missing.length > 0 ? "warning" : "success"}
+      title={i18next
+        .t("usage:Priced {updated} of {considered} models")
+        .replace("{updated}", String(result.updated.length))
+        .replace("{considered}", String(result.considered.length))}
+      description={
+        <div className="flex flex-col gap-2">
+          {line("usage:Still unpriced", result.missing)}
+          {line("usage:Left as edited", result.skipped)}
+          {line("usage:Updated", result.updated)}
+          <span className="text-xs">
+            {i18next
+              .t("usage:Read from a catalogue of {count} models")
+              .replace("{count}", result.catalogue.toLocaleString())}
+          </span>
+        </div>
+      }
+      action={
+        <Button size="sm" variant="ghost" onClick={onDismiss}>
+          {i18next.t("general:Close")}
+        </Button>
+      }
+    />
+  );
+}
+
+export default function PricingPage({account}: {account: Account}) {
+  const isAdmin = Setting.isAdminUser(account);
+  const [prices, setPrices] = React.useState<LlmPriceView[]>([]);
+  const [loading, setLoading] = React.useState(true);
+  const [error, setError] = React.useState("");
+  const [syncing, setSyncing] = React.useState(false);
+  const [sync, setSync] = React.useState<ModelsDevSync | null>(null);
+  const [editing, setEditing] = React.useState<LlmPriceView | null>(null);
+  const [dialogOpen, setDialogOpen] = React.useState(false);
+  const [saving, setSaving] = React.useState(false);
+
+  const load = React.useCallback(() => {
+    if (!isAdmin) {
+      return;
+    }
+    LlmPriceBackend.getLlmPrices()
+      .then(res => {
+        if (res.status === "ok") {
+          setPrices(res.data ?? []);
+          setError("");
+        } else {
+          setError(res.msg || i18next.t("general:Failed to get data"));
+        }
+      })
+      .catch(failure => setError(failure.message || String(failure)))
+      .then(() => setLoading(false));
+  }, [isAdmin]);
+
+  React.useEffect(() => {
+    load();
+  }, [load]);
+
+  if (!isAdmin) {
+    return <UnauthorizedResult />;
+  }
+
+  const save = (entry: Partial<LlmPriceEntry>) => {
+    setSaving(true);
+    LlmPriceBackend.updateLlmPrice(entry)
+      .then(res => {
+        if (res.status === "ok") {
+          Setting.showMessage("success", i18next.t("general:Successfully 
saved"));
+          setDialogOpen(false);
+          load();
+        } else {
+          Setting.showMessage("error", `${i18next.t("general:Failed to 
save")}: ${res.msg}`);
+        }
+      })
+      .catch(failure => Setting.showMessage("error", 
`${i18next.t("general:Failed to save")}: ${failure}`))
+      .then(() => setSaving(false));
+  };
+
+  const reset = (model: string) =>
+    LlmPriceBackend.deleteLlmPrice(model)
+      .then(res => {
+        if (res.status === "ok") {
+          Setting.showMessage("success", i18next.t("general:Deleted 
successfully"));
+          load();
+        } else {
+          Setting.showMessage("error", `${i18next.t("general:Failed to 
delete")}: ${res.msg}`);
+        }
+      })
+      .catch(failure => Setting.showMessage("error", 
`${i18next.t("general:Failed to delete")}: ${failure}`));
+
+  const runSync = () => {
+    setSyncing(true);
+    setSync(null);
+    LlmPriceBackend.syncModelsDevPrices()
+      .then(res => {
+        if (res.status === "ok" && res.data) {
+          setSync(res.data);
+          load();
+        } else {
+          Setting.showMessage("error", res.msg || i18next.t("general:Failed to 
get data"));
+        }
+      })
+      .catch(failure => Setting.showMessage("error", failure.message || 
String(failure)))
+      .then(() => setSyncing(false));
+  };
+
+  const open = (price: LlmPriceView | null) => {
+    setEditing(price);
+    setDialogOpen(true);
+  };
+
+  const rate = (key: "input" | "output" | "cacheWrite" | "cacheRead" | 
"cacheWrite1h"): Column<LlmPriceView> => ({
+    key: key,
+    title: i18next.t(
+      {
+        input: "usage:Input",
+        output: "llm:Output",
+        cacheWrite: "llm:Cache write",
+        cacheRead: "llm:Cache read",
+        cacheWrite1h: "usage:Cache write 1h",
+      }[key],
+    ),
+    align: "right",
+    width: "110px",
+    sorter: (left, right) => left[key] - right[key],
+    render: (_value, price) => (
+      <span className={price[key] === 0 ? "text-muted-foreground tabular-nums" 
: "tabular-nums"}>
+        {formatRate(price[key])}
+      </span>
+    ),
+  });
+
+  const columns: Column<LlmPriceView>[] = [
+    {
+      key: "model",
+      title: i18next.t("agent:Model"),
+      sorter: (left, right) => left.model.localeCompare(right.model),
+      render: (_value, price) => (
+        <div className="flex min-w-0 flex-col">
+          <span className="truncate font-mono text-xs">{price.model}</span>
+          {price.displayName ? (
+            <span className="text-muted-foreground truncate 
text-xs">{price.displayName}</span>
+          ) : null}
+        </div>
+      ),
+    },
+    rate("input"),
+    rate("output"),
+    rate("cacheWrite"),
+    rate("cacheRead"),
+    rate("cacheWrite1h"),
+    {
+      key: "source",
+      title: i18next.t("usage:Source"),
+      width: "130px",
+      render: (_value, price) => (
+        <Badge variant={SOURCE_TONE[price.source] ?? 
"muted"}>{i18next.t(SOURCE_LABEL[price.source] ?? price.source)}</Badge>
+      ),
+    },
+    {
+      key: "actions",
+      title: "",
+      width: "96px",
+      align: "right",
+      render: (_value, price) => (
+        <div className="flex items-center justify-end gap-1">
+          <SimpleTooltip title={i18next.t("general:Edit")}>
+            <Button size="icon" variant="ghost" onClick={() => open(price)}>
+              <Pencil />
+            </Button>
+          </SimpleTooltip>
+          {price.source === "built-in" || price.source === "file" ? null : (
+            <ConfirmDialog
+              title={
+                price.overridden
+                  ? i18next.t("usage:Put the built-in price back?")
+                  : i18next.t("usage:Delete this price?")
+              }
+              description={
+                price.overridden
+                  ? i18next.t("usage:Put the built-in price back detail")
+                  : i18next.t("usage:Delete this price detail")
+              }
+              onConfirm={() => reset(price.model)}
+            >
+              <Button size="icon" variant="ghost">
+                {price.overridden ? <RotateCcw /> : <Trash2 />}
+              </Button>
+            </ConfirmDialog>
+          )}
+        </div>
+      ),
+    },
+  ];
+
+  return (
+    <PageContainer>
+      <PageHeader
+        title={i18next.t("usage:Model pricing")}
+        description={i18next.t("usage:Model pricing description")}
+        actions={
+          <>
+            <Button variant="outline" onClick={runSync} loading={syncing}>
+              <CloudDownload />
+              {i18next.t("usage:Sync from models.dev")}
+            </Button>
+            <Button onClick={() => open(null)}>
+              <Plus />
+              {i18next.t("usage:Add a price")}
+            </Button>
+          </>
+        }
+      />
+
+      {error ? <MessageAlert title={error} /> : null}
+      {sync ? <SyncReport result={sync} onDismiss={() => setSync(null)} /> : 
null}
+
+      <DataTable
+        searchable
+        searchPlaceholder={i18next.t("agent:Model")}
+        columns={columns}
+        dataSource={prices}
+        rowKey={price => price.model}
+        loading={loading}
+        pageSize={25}
+        emptyIcon={CircleDollarSign}
+        emptyText={i18next.t("usage:No prices yet")}
+      />
+
+      <PriceEditDialog
+        open={dialogOpen}
+        onOpenChange={setDialogOpen}
+        price={editing}
+        onSave={save}
+        saving={saving}
+      />
+    </PageContainer>
+  );
+}
diff --git a/web/src/types.ts b/web/src/types.ts
index 5fb0ae1..cd0c887 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -311,6 +311,60 @@ export interface LlmPrice {
   cacheRead: number;
 }
 
+/** Where a price in effect came from, in the order each overrides the one 
before. */
+export type LlmPriceSource = "built-in" | "file" | "models.dev" | "manual";
+
+/** A stored override of what a model costs, as the edit dialog sends it. */
+export interface LlmPriceEntry {
+  model: string;
+  displayName: string;
+  input: number;
+  output: number;
+  cacheWrite: number;
+  cacheRead: number;
+  cacheWrite1h: number;
+  source: LlmPriceSource;
+  updatedTime: string;
+}
+
+/** One model's price as it is actually in effect, with the layer it came 
from. */
+export interface LlmPriceView {
+  model: string;
+  displayName: string;
+  input: number;
+  output: number;
+  cacheWrite: number;
+  cacheRead: number;
+  cacheWrite1h: number;
+  source: LlmPriceSource;
+  /** True where a stored row stands in front of a built-in price. */
+  overridden: boolean;
+}
+
+/** One model of the models.dev catalogue, priced at what most of the providers
+ *  listing it agree that it costs. */
+export interface ModelsDevModel {
+  model: string;
+  displayName: string;
+  releaseDate: string;
+  providers: number;
+  input: number;
+  output: number;
+  cacheRead: number;
+  cacheWrite: number;
+}
+
+/** What one models.dev sync did. `missing` is the half that matters: those are
+ *  the models still costing nothing. */
+export interface ModelsDevSync {
+  catalogue: number;
+  considered: string[];
+  updated: string[];
+  skipped: string[];
+  missing: string[];
+  syncedTime: string;
+}
+
 export interface LlmModelStat {
   model: string;
   requests: number;


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to