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 4e82615  feat(pricing): sync model prices from models.dev on a schedule
4e82615 is described below

commit 4e82615633363c9acdfe3b5ba61147f218025361
Author: Yang Luo <[email protected]>
AuthorDate: Thu Sep 3 14:38:40 2026 +0800

    feat(pricing): sync model prices from models.dev on a schedule
---
 conf/conf.go                                       |  11 ++
 controllers/agent.go                               |  33 +---
 controllers/llm_price.go                           |  76 +++++---
 main.go                                            |   2 +
 object/agent_usage.go                              |  28 +++
 object/models_dev_sync.go                          | 210 ++++++++++++++++++++
 object/setting.go                                  |  20 ++
 routers/router.go                                  |   2 +
 web/src/backend/LlmPriceBackend.ts                 |  22 ++-
 web/src/components/usage/models-dev-sync-panel.tsx | 211 +++++++++++++++++++++
 web/src/locales/en/data.json                       |  14 ++
 web/src/locales/zh/data.json                       |  14 ++
 web/src/pages/PricingPage.tsx                      |  83 +-------
 web/src/types.ts                                   |  19 ++
 14 files changed, 611 insertions(+), 134 deletions(-)

diff --git a/conf/conf.go b/conf/conf.go
index 7505b0a..bc62ea1 100644
--- a/conf/conf.go
+++ b/conf/conf.go
@@ -303,6 +303,17 @@ func GetAgentMonitorPollSeconds() int {
        return res
 }
 
+// GetModelsDevSyncIntervalHours is how often an automatic models.dev sync 
runs.
+// The catalogue only changes when a vendor publishes a price, so once a day is
+// already more often than it has anything new to say.
+func GetModelsDevSyncIntervalHours() int {
+       res, err := strconv.Atoi(GetConfigString("modelsDevSyncIntervalHours"))
+       if err != nil || res <= 0 {
+               res = 24
+       }
+       return res
+}
+
 // How much of an LLM request Gateway retains.
 const (
        LlmRecordOff      = "off"
diff --git a/controllers/agent.go b/controllers/agent.go
index 227f748..0b7f42d 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -25,7 +25,6 @@ import (
 
        "github.com/apache/casbin-gateway/agent"
        "github.com/apache/casbin-gateway/agenthistory"
-       "github.com/apache/casbin-gateway/agenthome"
        "github.com/apache/casbin-gateway/agentinstall"
        "github.com/apache/casbin-gateway/agentmonitor"
        "github.com/apache/casbin-gateway/agentpatch"
@@ -275,7 +274,7 @@ func (c *ApiController) GetAgentSessions() {
                        LastTime:    session.LastTime,
                })
        }
-       for _, session := range historicalSessions(agentId) {
+       for _, session := range object.HistoricalSessions(agentId) {
                if seen[sessionSeenKey(session.Agent, session.SessionKey)] {
                        continue
                }
@@ -304,7 +303,7 @@ func (c *ApiController) GetAgentSession() {
                return
        }
 
-       for _, session := range historicalSessions(agentId) {
+       for _, session := range object.HistoricalSessions(agentId) {
                if session.SessionKey != sessionKey {
                        continue
                }
@@ -345,7 +344,7 @@ func (c *ApiController) GetAgentUsage() {
                        since = time.Now().AddDate(0, 0, -(days - 
1)).Format(time.DateOnly)
                }
        }
-       
c.ResponseOk(object.GetAgentUsage(historicalSessions(c.Input().Get("agent")), 
since))
+       
c.ResponseOk(object.GetAgentUsage(object.HistoricalSessions(c.Input().Get("agent")),
 since))
 }
 
 // sessionSeenKey identifies one session across the two sources, so a session
@@ -354,32 +353,6 @@ func sessionSeenKey(agentId string, sessionKey string) 
string {
        return agentId + "/" + sessionKey
 }
 
-// historicalSessions reads the transcripts of every account with an agent on
-// this machine. A home Gateway cannot open is skipped: the page lists what it
-// can read, and says nothing about the rest.
-func historicalSessions(agentId string) []agenthistory.Session {
-       installations, err := agent.Scan(false)
-       if err != nil {
-               return nil
-       }
-
-       sessions := []agenthistory.Session{}
-       scanned := map[string]bool{}
-       for _, installation := range installations {
-               home, err := agenthome.Resolve(installation.Owner)
-               if err != nil || scanned[home] {
-                       continue
-               }
-               scanned[home] = true
-               for _, session := range agenthistory.Scan(home) {
-                       if agentId == "" || strings.EqualFold(session.Agent, 
agentId) {
-                               sessions = append(sessions, session)
-                       }
-               }
-       }
-       return sessions
-}
-
 // AddAgentRecord accepts reports from a hook or MCP process launched locally 
by
 // Gateway. Those processes have no browser session, so they authenticate with
 // the per-installation credential issued at Patch time. Loopback alone is not 
a
diff --git a/controllers/llm_price.go b/controllers/llm_price.go
index 4c86448..f66ff69 100644
--- a/controllers/llm_price.go
+++ b/controllers/llm_price.go
@@ -16,7 +16,6 @@ package controllers
 
 import (
        "encoding/json"
-       "sort"
        "strings"
 
        "github.com/apache/casbin-gateway/object"
@@ -105,7 +104,7 @@ func (c *ApiController) SyncModelsDevPrices() {
                return
        }
 
-       result, err := object.SyncModelsDevPrices(seenModels(), 
c.Input().Get("refresh") == "true")
+       result, err := object.RunModelsDevSync(c.Input().Get("refresh") == 
"true")
        if err != nil {
                c.ResponseError(err.Error())
                return
@@ -114,41 +113,58 @@ func (c *ApiController) SyncModelsDevPrices() {
        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
-               }
+// GetModelsDevSync reports the schedule an automatic sync is on and what the
+// last run did, so the Pricing page can say when prices were last read and
+// which models the catalogue still cannot price.
+func (c *ApiController) GetModelsDevSync() {
+       if c.RequireAdmin() {
+               return
        }
 
-       if models, err := object.GetSeenLlmModels(); err == nil {
-               for _, model := range models {
-                       add(model)
-               }
+       c.ResponseOk(object.GetModelsDevSyncState())
+}
+
+// UpdateModelsDevSync turns the automatic sync on or off and sets how often it
+// runs. It writes the same two settings the Settings page would, so the choice
+// survives a restart.
+func (c *ApiController) UpdateModelsDevSync() {
+       if c.RequireAdmin() {
+               return
        }
 
-       for _, stat := range object.GetAgentUsage(historicalSessions(""), 
"").Models {
-               add(stat.Name)
+       var request struct {
+               Mode          string `json:"mode"`
+               IntervalHours int    `json:"intervalHours"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil 
{
+               c.ResponseError(err.Error())
+               return
+       }
+       if request.Mode != object.ModelsDevSyncAuto && request.Mode != 
object.ModelsDevSyncOff {
+               c.ResponseError("modelsDevSyncMode must be \"auto\" or \"off\"")
+               return
+       }
+       if request.IntervalHours <= 0 {
+               c.ResponseError("the sync interval must be at least one hour")
+               return
        }
 
-       if providers, err := object.GetProviders(""); err == nil {
-               for _, provider := range providers {
-                       for _, model := range provider.Models {
-                               add(model)
-                       }
-               }
+       setting, err := object.GetBuiltInSetting()
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       if setting == nil {
+               c.ResponseError("the built-in setting does not exist")
+               return
        }
 
-       models := make([]string, 0, len(found))
-       for model := range found {
-               models = append(models, model)
+       setting.ModelsDevSyncMode = request.Mode
+       setting.ModelsDevSyncIntervalHours = request.IntervalHours
+       if _, err := object.UpdateSetting(object.BuiltInSettingId, setting); 
err != nil {
+               c.ResponseError(err.Error())
+               return
        }
-       sort.Strings(models)
-       return models
+
+       c.ResponseOk(object.GetModelsDevSyncState())
 }
diff --git a/main.go b/main.go
index d75f6cd..e9023ca 100644
--- a/main.go
+++ b/main.go
@@ -78,6 +78,8 @@ func main() {
        // Probes go out over the same transport, so they can only start once 
it is
        // configured. The sweep waits a while longer before spending anything.
        object.StartProviderProbeSweep()
+       // Prices are read over the same transport, and only when the sync is 
on.
+       object.StartModelsDevSync()
 
        agentmonitor.Configure(
                conf.GetAgentPatchStateDir(),
diff --git a/object/agent_usage.go b/object/agent_usage.go
index 3ac3d7f..b8ffd3d 100644
--- a/object/agent_usage.go
+++ b/object/agent_usage.go
@@ -18,9 +18,37 @@ import (
        "sort"
        "strings"
 
+       "github.com/apache/casbin-gateway/agent"
        "github.com/apache/casbin-gateway/agenthistory"
+       "github.com/apache/casbin-gateway/agenthome"
 )
 
+// HistoricalSessions reads the transcripts of every account with an agent on
+// this machine. A home Gateway cannot open is skipped: the caller lists what 
it
+// can read, and says nothing about the rest.
+func HistoricalSessions(agentId string) []agenthistory.Session {
+       installations, err := agent.Scan(false)
+       if err != nil {
+               return nil
+       }
+
+       sessions := []agenthistory.Session{}
+       scanned := map[string]bool{}
+       for _, installation := range installations {
+               home, err := agenthome.Resolve(installation.Owner)
+               if err != nil || scanned[home] {
+                       continue
+               }
+               scanned[home] = true
+               for _, session := range agenthistory.Scan(home) {
+                       if agentId == "" || strings.EqualFold(session.Agent, 
agentId) {
+                               sessions = append(sessions, session)
+                       }
+               }
+       }
+       return sessions
+}
+
 // AgentUsageStat is what one agent, one model or one day spent. Which of the
 // three a stat counts is decided by the list it is in; Name is that key.
 type AgentUsageStat struct {
diff --git a/object/models_dev_sync.go b/object/models_dev_sync.go
new file mode 100644
index 0000000..9510e8f
--- /dev/null
+++ b/object/models_dev_sync.go
@@ -0,0 +1,210 @@
+// 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 (
+       "fmt"
+       "sort"
+       "strings"
+       "sync"
+       "time"
+
+       "github.com/apache/casbin-gateway/conf"
+       "github.com/apache/casbin-gateway/util"
+       "github.com/beego/beego"
+)
+
+// Whether the catalogue is read on a schedule. A sync is off by default: it
+// rewrites what every recorded request is said to have cost, and that is a
+// change somebody should ask for rather than find already made.
+const (
+       ModelsDevSyncAuto = "auto"
+       ModelsDevSyncOff  = "off"
+)
+
+// modelsDevSyncDelay lets the process finish starting before it pulls a few
+// megabytes, and modelsDevSyncTick is how often the schedule is reconsidered -
+// turning the sync on does not wait out a whole interval to take effect.
+const (
+       modelsDevSyncDelay = 90 * time.Second
+       modelsDevSyncTick  = 10 * time.Minute
+)
+
+// ModelsDevSyncState is the schedule and the last run, which is what the
+// Pricing page reports. Like ProviderHealth it lives in memory: it describes
+// what this process has done, not a stored setting.
+type ModelsDevSyncState struct {
+       Mode          string `json:"mode"`
+       IntervalHours int    `json:"intervalHours"`
+       Running       bool   `json:"running"`
+       SyncedTime    string `json:"syncedTime"`
+       // NextTime is when the schedule runs again, empty when nothing is 
scheduled.
+       NextTime string         `json:"nextTime"`
+       Error    string         `json:"error"`
+       Result   *ModelsDevSync `json:"result"`
+}
+
+var (
+       modelsDevSyncMutex   sync.Mutex
+       modelsDevSyncRunning bool
+       modelsDevSynced      time.Time
+       modelsDevSyncError   string
+       modelsDevSyncResult  *ModelsDevSync
+)
+
+func GetModelsDevSyncMode() string {
+       if strings.EqualFold(conf.GetConfigStringUnquoted("modelsDevSyncMode"), 
ModelsDevSyncAuto) {
+               return ModelsDevSyncAuto
+       }
+       return ModelsDevSyncOff
+}
+
+func GetModelsDevSyncState() *ModelsDevSyncState {
+       modelsDevSyncMutex.Lock()
+       defer modelsDevSyncMutex.Unlock()
+
+       state := &ModelsDevSyncState{
+               Mode:          GetModelsDevSyncMode(),
+               IntervalHours: conf.GetModelsDevSyncIntervalHours(),
+               Running:       modelsDevSyncRunning,
+               Error:         modelsDevSyncError,
+               Result:        modelsDevSyncResult,
+       }
+       if !modelsDevSynced.IsZero() {
+               state.SyncedTime = util.FormatTime(modelsDevSynced)
+               if state.Mode == ModelsDevSyncAuto {
+                       state.NextTime = 
util.FormatTime(modelsDevSynced.Add(modelsDevSyncInterval()))
+               }
+       }
+       return state
+}
+
+// StartModelsDevSync keeps the prices of the models this machine runs in step
+// with the catalogue. Nothing happens until the sync is turned on, and the
+// schedule is read each time round rather than held, so turning it off stops 
it.
+func StartModelsDevSync() {
+       go func() {
+               time.Sleep(modelsDevSyncDelay)
+               for {
+                       if isModelsDevSyncDue() {
+                               if _, err := RunModelsDevSync(true); err != nil 
{
+                                       beego.Error("models.dev sync failed:", 
err)
+                               }
+                       }
+                       time.Sleep(modelsDevSyncTick)
+               }
+       }()
+}
+
+func modelsDevSyncInterval() time.Duration {
+       return time.Duration(conf.GetModelsDevSyncIntervalHours()) * time.Hour
+}
+
+func isModelsDevSyncDue() bool {
+       if GetModelsDevSyncMode() != ModelsDevSyncAuto {
+               return false
+       }
+
+       modelsDevSyncMutex.Lock()
+       defer modelsDevSyncMutex.Unlock()
+       if modelsDevSyncRunning {
+               return false
+       }
+       // A run that failed is retried on the next tick rather than the next
+       // interval: the usual reason is a network that was down for a minute.
+       if modelsDevSynced.IsZero() {
+               return true
+       }
+       return time.Since(modelsDevSynced) >= modelsDevSyncInterval()
+}
+
+// RunModelsDevSync prices every model this machine has been seen running and
+// keeps what it did, so the page reports the last run whether it was asked for
+// or scheduled. Two syncs at once would only fetch the same catalogue twice.
+func RunModelsDevSync(force bool) (*ModelsDevSync, error) {
+       if !beginModelsDevSync() {
+               return nil, fmt.Errorf("a models.dev sync is already running")
+       }
+
+       result, err := SyncModelsDevPrices(SeenModelNames(), force)
+       endModelsDevSync(result, err)
+       return result, err
+}
+
+func beginModelsDevSync() bool {
+       modelsDevSyncMutex.Lock()
+       defer modelsDevSyncMutex.Unlock()
+
+       if modelsDevSyncRunning {
+               return false
+       }
+       modelsDevSyncRunning = true
+       return true
+}
+
+func endModelsDevSync(result *ModelsDevSync, err error) {
+       modelsDevSyncMutex.Lock()
+       defer modelsDevSyncMutex.Unlock()
+
+       modelsDevSyncRunning = false
+       if err != nil {
+               // The last successful run stays on the state: what it priced 
is still
+               // in effect, and the error says why there has been nothing 
since.
+               modelsDevSyncError = err.Error()
+               return
+       }
+       modelsDevSyncError = ""
+       modelsDevSyncResult = result
+       modelsDevSynced = time.Now()
+}
+
+// SeenModelNames 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 SeenModelNames() []string {
+       found := map[string]bool{}
+       add := func(name string) {
+               name = strings.ToLower(strings.TrimSpace(name))
+               if name != "" {
+                       found[name] = true
+               }
+       }
+
+       if models, err := GetSeenLlmModels(); err == nil {
+               for _, model := range models {
+                       add(model)
+               }
+       }
+
+       for _, stat := range GetAgentUsage(HistoricalSessions(""), "").Models {
+               add(stat.Name)
+       }
+
+       if providers, err := 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/setting.go b/object/setting.go
index 4d5253d..8b2fe55 100644
--- a/object/setting.go
+++ b/object/setting.go
@@ -44,6 +44,12 @@ type Setting struct {
        // a few cents of the account's own credit, so it is one setting away.
        ProviderProbeMode string `xorm:"varchar(20)" json:"providerProbeMode"`
 
+       // ModelsDevSyncMode is "auto" or "off", and the interval is how often 
an
+       // automatic sync runs. A sync only ever reprices models this machine 
has
+       // run, and never one that was priced by hand.
+       ModelsDevSyncMode          string `xorm:"varchar(20)" 
json:"modelsDevSyncMode"`
+       ModelsDevSyncIntervalHours int    `xorm:"int" 
json:"modelsDevSyncIntervalHours"`
+
        AgentPatchStateDir      string `xorm:"varchar(500)" 
json:"agentPatchStateDir"`
        AgentRecordCapacity     int    `xorm:"int" json:"agentRecordCapacity"`
        AgentMonitorPollSeconds int    `xorm:"int" 
json:"agentMonitorPollSeconds"`
@@ -79,6 +85,9 @@ func SyncSettingToConf(setting *Setting) {
 
                "providerProbeMode": setting.ProviderProbeMode,
 
+               "modelsDevSyncMode":          setting.ModelsDevSyncMode,
+               "modelsDevSyncIntervalHours": 
strconv.Itoa(setting.ModelsDevSyncIntervalHours),
+
                "agentPatchStateDir":      setting.AgentPatchStateDir,
                "agentRecordCapacity":     
strconv.Itoa(setting.AgentRecordCapacity),
                "agentMonitorPollSeconds": 
strconv.Itoa(setting.AgentMonitorPollSeconds),
@@ -185,6 +194,14 @@ func InitBuiltInSetting() {
                }
        }
 
+       if setting.ModelsDevSyncMode == "" {
+               setting.ModelsDevSyncMode = GetModelsDevSyncMode()
+               setting.ModelsDevSyncIntervalHours = 
conf.GetModelsDevSyncIntervalHours()
+               if _, err = ormer.Engine.ID(core.PK{setting.Owner, 
setting.Name}).Cols("models_dev_sync_mode", 
"models_dev_sync_interval_hours").Update(setting); err != nil {
+                       panic(err)
+               }
+       }
+
        SyncSettingToConf(setting)
 }
 
@@ -207,6 +224,9 @@ func newSettingFromConf() *Setting {
 
                ProviderProbeMode: GetProviderProbeMode(),
 
+               ModelsDevSyncMode:          GetModelsDevSyncMode(),
+               ModelsDevSyncIntervalHours: 
conf.GetModelsDevSyncIntervalHours(),
+
                AgentPatchStateDir:      conf.GetAgentPatchStateDir(),
                AgentRecordCapacity:     conf.GetAgentRecordCapacity(),
                AgentMonitorPollSeconds: conf.GetAgentMonitorPollSeconds(),
diff --git a/routers/router.go b/routers/router.go
index 0e210d5..b0a451e 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -113,6 +113,8 @@ func initAPI() {
        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")
+       beego.Router("/api/get-models-dev-sync", &controllers.ApiController{}, 
"GET:GetModelsDevSync")
+       beego.Router("/api/update-models-dev-sync", 
&controllers.ApiController{}, "POST:UpdateModelsDevSync")
 
        // The LLM gateway, in every wire format a client speaks. The agent 
routes
        // carry the endpoint shapes those clients append to one base URL.
diff --git a/web/src/backend/LlmPriceBackend.ts 
b/web/src/backend/LlmPriceBackend.ts
index 5a93ffd..87933c1 100644
--- a/web/src/backend/LlmPriceBackend.ts
+++ b/web/src/backend/LlmPriceBackend.ts
@@ -13,7 +13,14 @@
 // limitations under the License.
 
 import {query, request} from "@/backend/request";
-import type {LlmPriceEntry, LlmPriceView, ModelsDevModel, ModelsDevSync} from 
"@/types";
+import type {
+  LlmPriceEntry,
+  LlmPriceView,
+  ModelsDevModel,
+  ModelsDevSync,
+  ModelsDevSyncMode,
+  ModelsDevSyncState,
+} from "@/types";
 
 /** Every price in effect, built-in ones included, each with the layer it came 
from. */
 export function getLlmPrices() {
@@ -47,3 +54,16 @@ export function syncModelsDevPrices(refresh = true) {
     "POST",
   );
 }
+
+/** The schedule an automatic sync is on, and what the last run did. */
+export function getModelsDevSync() {
+  return request<ModelsDevSyncState>("/api/get-models-dev-sync");
+}
+
+/** Turns the automatic sync on or off and sets how often it runs. */
+export function updateModelsDevSync(mode: ModelsDevSyncMode, intervalHours: 
number) {
+  return request<ModelsDevSyncState>("/api/update-models-dev-sync", "POST", {
+    mode: mode,
+    intervalHours: intervalHours,
+  });
+}
diff --git a/web/src/components/usage/models-dev-sync-panel.tsx 
b/web/src/components/usage/models-dev-sync-panel.tsx
new file mode 100644
index 0000000..7051adf
--- /dev/null
+++ b/web/src/components/usage/models-dev-sync-panel.tsx
@@ -0,0 +1,211 @@
+// 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 {CloudDownload} from "lucide-react";
+import i18next from "i18next";
+
+import * as LlmPriceBackend from "@/backend/LlmPriceBackend";
+import * as Setting from "@/Setting";
+import {CodeText} from "@/components/shared/misc";
+import {SimpleSelect} from "@/components/shared/simple-select";
+import {MessageAlert} from "@/components/ui/alert";
+import {Button} from "@/components/ui/button";
+import {Card, CardContent, CardDescription, CardHeader, CardTitle} from 
"@/components/ui/card";
+import {Switch} from "@/components/ui/switch";
+import type {ModelsDevSync, ModelsDevSyncState} from "@/types";
+
+/** How often an automatic sync runs. The catalogue changes when a vendor
+ *  publishes a price, so even the shortest of these is often. */
+const INTERVALS = [6, 24, 168];
+
+function intervalLabel(hours: number) {
+  if (hours === 24) {
+    return i18next.t("usage:Once a day");
+  }
+  if (hours === 168) {
+    return i18next.t("usage:Once a week");
+  }
+  return i18next.t("usage:Every {count} hours").replace("{count}", 
String(hours));
+}
+
+/** 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}: {result: ModelsDevSync}) {
+  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>
+      }
+    />
+  );
+}
+
+/**
+ * The models.dev sync: whether it runs on its own, how often, and what the 
last
+ * run did. A sync only ever reprices the models this machine has been seen
+ * running, and never one that was edited by hand.
+ */
+export function ModelsDevSyncPanel({onSynced}: {onSynced: () => void}) {
+  const [state, setState] = React.useState<ModelsDevSyncState | null>(null);
+  const [syncing, setSyncing] = React.useState(false);
+  const [saving, setSaving] = React.useState(false);
+
+  React.useEffect(() => {
+    LlmPriceBackend.getModelsDevSync()
+      .then(res => {
+        if (res.status === "ok") {
+          setState(res.data);
+        }
+      })
+      .catch(() => undefined);
+  }, []);
+
+  if (state === null) {
+    return null;
+  }
+
+  const auto = state.mode === "auto";
+
+  const schedule = (mode: ModelsDevSyncState["mode"], intervalHours: number) 
=> {
+    setSaving(true);
+    LlmPriceBackend.updateModelsDevSync(mode, intervalHours)
+      .then(res => {
+        if (res.status === "ok" && res.data) {
+          setState(res.data);
+        } 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));
+  };
+
+  // The state carries the report, so the panel reads the same whether the run
+  // it is showing was asked for here or by the schedule.
+  const syncNow = () => {
+    setSyncing(true);
+    LlmPriceBackend.syncModelsDevPrices()
+      .then(res => {
+        if (res.status !== "ok") {
+          Setting.showMessage("error", res.msg || i18next.t("general:Failed to 
get data"));
+        }
+        onSynced();
+        return LlmPriceBackend.getModelsDevSync().then(latest => {
+          if (latest.status === "ok") {
+            setState(latest.data);
+          }
+        });
+      })
+      .catch(failure => Setting.showMessage("error", failure.message || 
String(failure)))
+      .then(() => setSyncing(false));
+  };
+
+  const fact = (label: string, value: React.ReactNode) => (
+    <div className="flex flex-col gap-0.5">
+      <span className="text-muted-foreground text-xs">{i18next.t(label)}</span>
+      <span className="text-sm">{value}</span>
+    </div>
+  );
+
+  return (
+    <Card className="gap-4 py-5">
+      <CardHeader className="px-5">
+        <div className="flex flex-wrap items-start justify-between gap-3">
+          <div className="min-w-0">
+            <CardTitle className="text-[15px]">{i18next.t("usage:Automatic 
sync")}</CardTitle>
+            <CardDescription>{i18next.t("usage:Automatic sync 
description")}</CardDescription>
+          </div>
+          <div className="flex shrink-0 items-center gap-2">
+            <span className="text-muted-foreground text-xs">
+              {i18next.t(auto ? "usage:Sync is on" : "usage:Sync is off")}
+            </span>
+            <Switch
+              checked={auto}
+              disabled={saving}
+              aria-label={i18next.t("usage:Automatic sync")}
+              onCheckedChange={checked => schedule(checked ? "auto" : "off", 
state.intervalHours)}
+            />
+          </div>
+        </div>
+      </CardHeader>
+
+      <CardContent className="flex flex-col gap-3 px-5">
+        <div className="grid gap-3 sm:grid-cols-3">
+          {fact(
+            "usage:Last sync",
+            state.syncedTime === ""
+              ? i18next.t("usage:Never synced")
+              : Setting.getFormattedDate(state.syncedTime),
+          )}
+          {fact(
+            "usage:Next sync",
+            auto
+              ? state.nextTime === ""
+                ? i18next.t("usage:Due now")
+                : Setting.getFormattedDate(state.nextTime)
+              : i18next.t("usage:Nothing is scheduled"),
+          )}
+          <div className="flex flex-col gap-1">
+            <span className="text-muted-foreground 
text-xs">{i18next.t("usage:How often")}</span>
+            <SimpleSelect
+              size="sm"
+              disabled={!auto || saving}
+              value={String(state.intervalHours)}
+              onChange={value => schedule(state.mode, Number(value))}
+              options={INTERVALS.map(hours => ({label: intervalLabel(hours), 
value: String(hours)}))}
+            />
+          </div>
+        </div>
+
+        {state.error === "" ? null : (
+          <MessageAlert title={i18next.t("usage:The last sync failed")} 
description={state.error} />
+        )}
+
+        {state.result === null ? null : <SyncReport result={state.result} />}
+
+        <div className="flex justify-end">
+          <Button variant="outline" onClick={syncNow} loading={syncing || 
state.running}>
+            <CloudDownload />
+            {i18next.t("usage:Sync from models.dev")}
+          </Button>
+        </div>
+      </CardContent>
+    </Card>
+  );
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 05c7471..c79f3a7 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -773,6 +773,8 @@
   "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.",
+    "Automatic sync": "Automatic sync",
+    "Automatic sync description": "Read the models.dev catalogue on a schedule 
and reprice the models this machine has been seen running. A price you edited 
by hand is left alone.",
     "Built in": "Built in",
     "By agent": "By agent",
     "By model": "By model",
@@ -782,22 +784,31 @@
     "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?",
+    "Due now": "Due now",
     "Edit price": "Edit price",
     "Edited by hand": "Edited by hand",
     "Every agent": "Every agent",
+    "Every {count} hours": "Every {count} hours",
     "Every {count}s": "Every {count}s",
+    "How often": "How often",
     "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 sync": "Last sync",
     "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.",
+    "Never synced": "Never",
+    "Next sync": "Next sync",
     "No auto refresh": "No auto refresh",
     "No model matches": "No model matches that search",
     "No prices yet": "No prices are configured",
+    "Nothing is scheduled": "Nothing is scheduled",
     "Nothing was spent in this window": "Nothing was spent in this window",
+    "Once a day": "Once a day",
+    "Once a week": "Once a week",
     "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",
@@ -814,6 +825,9 @@
     "Source": "Source",
     "Still unpriced": "Still unpriced:",
     "Sync from models.dev": "Sync from models.dev",
+    "Sync is off": "Off",
+    "Sync is on": "On",
+    "The last sync failed": "The last sync failed",
     "Tokens spent": "Tokens spent",
     "Trend": "Trend",
     "Updated": "Updated:",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 3915412..37c8b95 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -773,6 +773,8 @@
   "usage": {
     "Add a price": "新增价格",
     "Agents description": "这台机器上的 agent 花掉的量,读自它们自己写的会话记录。没有经过 Gateway 
的请求,只有这一份账。",
+    "Automatic sync": "自动同步",
+    "Automatic sync description": "定期读取 models.dev 
目录,为本机跑过的模型重新定价。你手改过的价格不会被动。",
     "Built in": "内置",
     "By agent": "按 Agent",
     "By model": "按模型",
@@ -782,22 +784,31 @@
     "Cache writes are not reported by every protocol": "走 OpenAI 协议的 provider 
只上报缓存命中,不上报缓存写入,所以这里可能显示为 0 而缓存其实在写。",
     "Delete this price detail": "这个模型没有内置价格,删掉后用到它的请求将不再计入成本,直到重新设置价格。",
     "Delete this price?": "删除这个价格?",
+    "Due now": "即将进行",
     "Edit price": "编辑价格",
     "Edited by hand": "手动修改",
     "Every agent": "全部 Agent",
+    "Every {count} hours": "每 {count} 小时",
     "Every {count}s": "每 {count} 秒",
+    "How often": "频率",
     "Import from models.dev": "从 models.dev 导入",
     "Import from models.dev description": "选一个模型,把它公布的价格填进表单。models.dev 
会把同一个模型列在每一家转售它的厂商下,所以这里每一档取的是多数厂商一致的那个价。",
     "Input": "输入",
+    "Last sync": "上次同步",
     "Last {count} days": "最近 {count} 天",
     "Left as edited": "保留你的修改:",
     "Model key hint": "按子串匹配请求里的模型名,所以填 \"claude-opus-5\" 也会给 
\"claude-opus-5-20260214\" 计价。",
     "Model pricing": "模型定价",
     "Model pricing description": "每百万 token 
的价格,用量页上的每个金额都由它算出。这里存的价格会覆盖对应模型的内置价。",
+    "Never synced": "从未同步",
+    "Next sync": "下次同步",
     "No auto refresh": "不自动刷新",
     "No model matches": "没有匹配的模型",
     "No prices yet": "还没有配置任何价格",
+    "Nothing is scheduled": "未安排",
     "Nothing was spent in this window": "这个时间范围内没有消耗",
+    "Once a day": "每天一次",
+    "Once a week": "每周一次",
     "One provider lists it": "1 家收录",
     "Price dialog description": "单位是美元每百万 token。这里保存的价格从此归你所有,models.dev 
同步不会覆盖它。",
     "Priced {updated} of {considered} models": "这台机器跑过 {considered} 个模型,给其中 
{updated} 个定了价",
@@ -814,6 +825,9 @@
     "Source": "来源",
     "Still unpriced": "仍未定价:",
     "Sync from models.dev": "从 models.dev 同步",
+    "Sync is off": "已关闭",
+    "Sync is on": "已开启",
+    "The last sync failed": "上次同步失败",
     "Tokens spent": "消耗 Tokens",
     "Trend": "趋势",
     "Updated": "已更新:",
diff --git a/web/src/pages/PricingPage.tsx b/web/src/pages/PricingPage.tsx
index c716be9..19a4e99 100644
--- a/web/src/pages/PricingPage.tsx
+++ b/web/src/pages/PricingPage.tsx
@@ -13,22 +13,23 @@
 // limitations under the License.
 
 import * as React from "react";
-import {CircleDollarSign, CloudDownload, Pencil, Plus, RotateCcw, Trash2} from 
"lucide-react";
+import {CircleDollarSign, Pencil, Plus, RotateCcw, Trash2} from "lucide-react";
 import i18next from "i18next";
 
 import * as LlmPriceBackend from "@/backend/LlmPriceBackend";
 import * as Setting from "@/Setting";
+import {ModelsDevSyncPanel} from "@/components/usage/models-dev-sync-panel";
 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 {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";
+import type {Account, LlmPriceEntry, LlmPriceSource, LlmPriceView} from 
"@/types";
 
 const SOURCE_TONE: Record<LlmPriceSource, BadgeVariant> = {
   "built-in": "muted",
@@ -44,54 +45,11 @@ const SOURCE_LABEL: Record<LlmPriceSource, string> = {
   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);
@@ -149,22 +107,6 @@ export default function PricingPage({account}: {account: 
Account}) {
       })
       .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);
@@ -260,21 +202,16 @@ export default function PricingPage({account}: {account: 
Account}) {
         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>
-          </>
+          <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}
+
+      <ModelsDevSyncPanel onSynced={load} />
 
       <DataTable
         searchable
diff --git a/web/src/types.ts b/web/src/types.ts
index d3a1f9d..8ce9225 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -392,6 +392,22 @@ export interface ModelsDevModel {
   cacheWrite: number;
 }
 
+/** Whether the catalogue is read on a schedule. */
+export type ModelsDevSyncMode = "auto" | "off";
+
+/** The schedule an automatic sync is on, and what the last run did. Held in
+ *  memory by the process, so it is empty again after a restart. */
+export interface ModelsDevSyncState {
+  mode: ModelsDevSyncMode;
+  intervalHours: number;
+  running: boolean;
+  syncedTime: string;
+  /** When the schedule runs again, empty when nothing is scheduled. */
+  nextTime: string;
+  error: string;
+  result: ModelsDevSync | null;
+}
+
 /** What one models.dev sync did. `missing` is the half that matters: those are
  *  the models still costing nothing. */
 export interface ModelsDevSync {
@@ -615,6 +631,9 @@ export interface Setting {
 
   providerProbeMode: ProviderProbeMode;
 
+  modelsDevSyncMode: ModelsDevSyncMode;
+  modelsDevSyncIntervalHours: number;
+
   agentPatchStateDir: string;
   agentRecordCapacity: number;
   agentMonitorPollSeconds: number;


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

Reply via email to