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 e360689  feat(usage): read agent spend from transcripts, not just 
relayed traffic
e360689 is described below

commit e3606894348d8cd52d85abc29cfb4d3ae99595cb
Author: Yang Luo <[email protected]>
AuthorDate: Wed Sep 2 20:37:21 2026 +0800

    feat(usage): read agent spend from transcripts, not just relayed traffic
---
 agenthistory/history.go              |  16 +++
 agenthistory/usage.go                | 212 +++++++++++++++++++++++++++++++++++
 controllers/agent.go                 |  28 +++++
 object/agent_usage.go                | 177 +++++++++++++++++++++++++++++
 object/llm_pricing.go                | 100 +++++++++++++----
 routers/router.go                    |   1 +
 web/src/backend/AgentBackend.ts      |  13 +++
 web/src/components/AgentGridCard.tsx |  42 +++++--
 web/src/lib/agents.ts                |  16 +++
 web/src/locales/en/data.json         |   2 +
 web/src/locales/zh/data.json         |   2 +
 web/src/pages/HomePage.tsx           |  40 ++++++-
 web/src/types.ts                     |  40 +++++++
 13 files changed, 657 insertions(+), 32 deletions(-)

diff --git a/agenthistory/history.go b/agenthistory/history.go
index 7642672..c106be1 100644
--- a/agenthistory/history.go
+++ b/agenthistory/history.go
@@ -45,6 +45,10 @@ type Session struct {
        Cwd string `json:"cwd"`
        // Historical marks a session read off disk rather than collected by 
monitoring.
        Historical bool `json:"historical"`
+       // Usage is what the transcript says the session spent. LLM Records 
only ever
+       // sees what went through Gateway, so this is the only account of an 
agent
+       // that talks to its vendor directly.
+       Usage []UsageBucket `json:"usage"`
 }
 
 const (
@@ -185,6 +189,7 @@ type line struct {
        Type      string          `json:"type"`
        Timestamp string          `json:"timestamp"`
        SessionId string          `json:"sessionId"`
+       RequestId string          `json:"requestId"`
        Cwd       string          `json:"cwd"`
        Message   json.RawMessage `json:"message"`
        Payload   struct {
@@ -193,6 +198,10 @@ type line struct {
                Role    string          `json:"role"`
                Cwd     string          `json:"cwd"`
                Content json.RawMessage `json:"content"`
+               // Model names what a Codex turn runs on, and Info carries the 
token
+               // counts of the turn that just ended.
+               Model string          `json:"model"`
+               Info  json.RawMessage `json:"info"`
        } `json:"payload"`
 }
 
@@ -255,6 +264,10 @@ func parse(agent string, file transcript) (Session, bool) {
        // the transcript itself overrides this when it names one.
        session.SessionKey = sessionKeyFromName(file.info.Name())
 
+       // Reading the file is the expensive part, so what it spent is added up 
on
+       // the same pass rather than by walking every transcript a second time.
+       usage := newUsageReader()
+
        if _, err := eachLine(file.path, func(data []byte) {
                var entry line
                if err := json.Unmarshal(data, &entry); err != nil {
@@ -277,6 +290,8 @@ func parse(agent string, file transcript) (Session, bool) {
                        session.LastTime = when
                }
 
+               usage.add(entry)
+
                if role, content := roleAndContent(entry); role != "" {
                        session.RecordCount++
                        if session.Title == "" && role == "user" {
@@ -293,6 +308,7 @@ func parse(agent string, file transcript) (Session, bool) {
        if session.FirstTime == "" {
                session.FirstTime = session.LastTime
        }
+       session.Usage = usage.buckets(dayOf(session.LastTime))
        return session, true
 }
 
diff --git a/agenthistory/usage.go b/agenthistory/usage.go
new file mode 100644
index 0000000..e640d7d
--- /dev/null
+++ b/agenthistory/usage.go
@@ -0,0 +1,212 @@
+// 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 agenthistory
+
+import (
+       "encoding/json"
+       "sort"
+       "strconv"
+       "strings"
+       "time"
+)
+
+// UsageBucket totals what one session spent on one model on one day. The day 
is
+// what a trend is drawn from, and keeping it here means the per-file cache 
holds
+// the answer instead of the turns it was added up from.
+type UsageBucket struct {
+       Model            string `json:"model"`
+       Day              string `json:"day"`
+       Requests         int    `json:"requests"`
+       PromptTokens     int    `json:"promptTokens"`
+       CompletionTokens int    `json:"completionTokens"`
+       CacheReadTokens  int    `json:"cacheReadTokens"`
+       CacheWriteTokens int    `json:"cacheWriteTokens"`
+       ReasoningTokens  int    `json:"reasoningTokens"`
+       // LongCacheTokens is the share of CacheWriteTokens written to live an 
hour
+       // rather than five minutes, which costs more. It is part of that 
count, not
+       // an amount on top of it.
+       LongCacheTokens int `json:"longCacheTokens"`
+}
+
+// turn is one model call, before it is folded into a bucket.
+type turn struct {
+       model      string
+       day        string
+       prompt     int
+       completion int
+       cacheRead  int
+       cacheWrite int
+       longCache  int
+       reasoning  int
+}
+
+// usageReader accumulates the turns of one transcript while its lines are 
read.
+type usageReader struct {
+       turns map[string]turn
+       // model is what the Codex turn in progress runs on: its token_count 
events
+       // do not name a model, the turn_context ahead of them does.
+       model string
+       next  int
+}
+
+func newUsageReader() *usageReader {
+       return &usageReader{turns: map[string]turn{}}
+}
+
+// add reads one line for what it spent. Both formats report usage, in 
different
+// places: Claude Code hangs it off the assistant message, Codex emits a
+// token_count event after every turn.
+func (reader *usageReader) add(entry line) {
+       switch {
+       case entry.Type == "turn_context" && entry.Payload.Model != "":
+               reader.model = entry.Payload.Model
+       case entry.Type == "assistant":
+               reader.addClaude(entry)
+       case entry.Type == "event_msg" && entry.Payload.Type == "token_count":
+               reader.addCodex(entry)
+       }
+}
+
+func (reader *usageReader) addClaude(entry line) {
+       var message struct {
+               Id    string `json:"id"`
+               Model string `json:"model"`
+               Usage struct {
+                       InputTokens    int `json:"input_tokens"`
+                       OutputTokens   int `json:"output_tokens"`
+                       CacheCreation  int `json:"cache_creation_input_tokens"`
+                       CacheReadInput int `json:"cache_read_input_tokens"`
+                       // The breakdown says how long each part of the cache 
was written
+                       // for, which is what separates the 1.25x rate from the 
2x one.
+                       CacheCreationBy struct {
+                               OneHour int `json:"ephemeral_1h_input_tokens"`
+                       } `json:"cache_creation"`
+               } `json:"usage"`
+       }
+       if err := json.Unmarshal(entry.Message, &message); err != nil {
+               return
+       }
+
+       usage := message.Usage
+       if usage.InputTokens == 0 && usage.OutputTokens == 0 && 
usage.CacheCreation == 0 && usage.CacheReadInput == 0 {
+               return
+       }
+       reader.put(firstNonEmpty(entry.RequestId, message.Id), turn{
+               model:      message.Model,
+               day:        dayOf(entry.Timestamp),
+               prompt:     usage.InputTokens,
+               completion: usage.OutputTokens,
+               cacheRead:  usage.CacheReadInput,
+               cacheWrite: usage.CacheCreation,
+               longCache:  usage.CacheCreationBy.OneHour,
+       })
+}
+
+func (reader *usageReader) addCodex(entry line) {
+       var info struct {
+               // Codex reports both the running total and the turn that just 
ended.
+               // The turn is what is added up here, so a resumed session that 
restarts
+               // its total does not count everything before it a second time.
+               Last struct {
+                       InputTokens     int `json:"input_tokens"`
+                       CachedInput     int `json:"cached_input_tokens"`
+                       OutputTokens    int `json:"output_tokens"`
+                       ReasoningOutput int `json:"reasoning_output_tokens"`
+               } `json:"last_token_usage"`
+       }
+       if err := json.Unmarshal(entry.Payload.Info, &info); err != nil {
+               return
+       }
+
+       last := info.Last
+       // The event fired before the first answer carries no counts at all.
+       if last.InputTokens == 0 && last.OutputTokens == 0 {
+               return
+       }
+       // Codex counts the cached part inside input_tokens, while every other
+       // counter here is of what was billed fresh, so it comes back out.
+       prompt := last.InputTokens - last.CachedInput
+       if prompt < 0 {
+               prompt = 0
+       }
+       reader.put("", turn{
+               model:      reader.model,
+               day:        dayOf(entry.Timestamp),
+               prompt:     prompt,
+               completion: last.OutputTokens,
+               cacheRead:  last.CachedInput,
+               reasoning:  last.ReasoningOutput,
+       })
+}
+
+// put records one turn under the id of the request that produced it. A second
+// line for the same request replaces the first rather than adding to it: 
Claude
+// Code rewrites the assistant message as it streams, and every copy carries 
the
+// whole turn's usage.
+func (reader *usageReader) put(id string, value turn) {
+       if id == "" {
+               reader.next++
+               id = "#" + strconv.Itoa(reader.next)
+       }
+       reader.turns[id] = value
+}
+
+// buckets folds the turns into one entry per model and day. A turn the
+// transcript gave no timestamp is dated by the session it belongs to.
+func (reader *usageReader) buckets(fallbackDay string) []UsageBucket {
+       if len(reader.turns) == 0 {
+               return nil
+       }
+
+       grouped := map[string]*UsageBucket{}
+       for _, value := range reader.turns {
+               day := firstNonEmpty(value.day, fallbackDay)
+               bucket, found := grouped[value.model+"\x00"+day]
+               if !found {
+                       bucket = &UsageBucket{Model: value.model, Day: day}
+                       grouped[value.model+"\x00"+day] = bucket
+               }
+               bucket.Requests++
+               bucket.PromptTokens += value.prompt
+               bucket.CompletionTokens += value.completion
+               bucket.CacheReadTokens += value.cacheRead
+               bucket.CacheWriteTokens += value.cacheWrite
+               bucket.LongCacheTokens += value.longCache
+               bucket.ReasoningTokens += value.reasoning
+       }
+
+       buckets := make([]UsageBucket, 0, len(grouped))
+       for _, bucket := range grouped {
+               buckets = append(buckets, *bucket)
+       }
+       // Sorted so that the value held in the cache does not shuffle between 
reads.
+       sort.SliceStable(buckets, func(left, right int) bool {
+               if buckets[left].Day != buckets[right].Day {
+                       return buckets[left].Day < buckets[right].Day
+               }
+               return buckets[left].Model < buckets[right].Model
+       })
+       return buckets
+}
+
+// dayOf is the local calendar day a turn happened on, which is what a daily
+// total means to whoever reads it.
+func dayOf(timestamp string) string {
+       when, err := time.Parse(time.RFC3339, strings.TrimSpace(timestamp))
+       if err != nil {
+               return ""
+       }
+       return when.Local().Format(time.DateOnly)
+}
diff --git a/controllers/agent.go b/controllers/agent.go
index 6bd40bd..46a1deb 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -21,6 +21,7 @@ import (
        "sort"
        "strconv"
        "strings"
+       "time"
 
        "github.com/apache/casbin-gateway/agent"
        "github.com/apache/casbin-gateway/agenthistory"
@@ -302,6 +303,33 @@ func (c *ApiController) GetAgentSession() {
        c.ResponseError("no transcript on disk for this session")
 }
 
+// GetAgentUsage totals what the agents on this machine spent, read from the
+// transcripts they write themselves rather than from the requests Gateway
+// relayed. LLM Records is empty until an agent is routed through the gateway,
+// and stays empty for one talking to its vendor directly; the transcripts are
+// what it cost either way.
+//
+// The optional days narrows the window to that many calendar days ending 
today,
+// and the optional agent to one agent's own transcripts.
+func (c *ApiController) GetAgentUsage() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       since := ""
+       if value := c.Input().Get("days"); value != "" {
+               days, err := strconv.Atoi(value)
+               if err != nil {
+                       c.ResponseError(err.Error())
+                       return
+               }
+               if days > 0 {
+                       since = time.Now().AddDate(0, 0, -(days - 
1)).Format(time.DateOnly)
+               }
+       }
+       
c.ResponseOk(object.GetAgentUsage(historicalSessions(c.Input().Get("agent")), 
since))
+}
+
 // sessionSeenKey identifies one session across the two sources, so a session
 // that monitoring already reported is not listed twice.
 func sessionSeenKey(agentId string, sessionKey string) string {
diff --git a/object/agent_usage.go b/object/agent_usage.go
new file mode 100644
index 0000000..3ac3d7f
--- /dev/null
+++ b/object/agent_usage.go
@@ -0,0 +1,177 @@
+// 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 (
+       "sort"
+       "strings"
+
+       "github.com/apache/casbin-gateway/agenthistory"
+)
+
+// 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 {
+       Name             string  `json:"name"`
+       Requests         int64   `json:"requests"`
+       PromptTokens     int64   `json:"promptTokens"`
+       CompletionTokens int64   `json:"completionTokens"`
+       CacheReadTokens  int64   `json:"cacheReadTokens"`
+       CacheWriteTokens int64   `json:"cacheWriteTokens"`
+       ReasoningTokens  int64   `json:"reasoningTokens"`
+       TotalTokens      int64   `json:"totalTokens"`
+       Cost             float64 `json:"cost"`
+       // Unpriced counts the requests left out of Cost because no list price
+       // matched their model, so a total is never quietly short.
+       Unpriced int64 `json:"unpriced"`
+       // LastTime and LastModel are filled for agents, which is what the page
+       // listing every agent shows next to its totals.
+       LastTime  string `json:"lastTime"`
+       LastModel string `json:"lastModel"`
+}
+
+// AgentUsage is what the transcripts on this machine add up to.
+type AgentUsage struct {
+       Totals AgentUsageStat   `json:"totals"`
+       Agents []AgentUsageStat `json:"agents"`
+       Models []AgentUsageStat `json:"models"`
+       // Days is ordered oldest first, which is the order a trend is drawn in.
+       Days []AgentUsageStat `json:"days"`
+       // Sessions is how many transcripts the totals were read from.
+       Sessions int `json:"sessions"`
+       // Since repeats the window the totals cover, so a page cannot label 
them
+       // with a range they were not asked for.
+       Since string `json:"since"`
+}
+
+// unknownModel stands in for a turn whose transcript never named a model. It 
is
+// priced like any other unknown one, which is to say not at all.
+const unknownModel = "unknown"
+
+// GetAgentUsage totals what the agents spent, read from the transcripts they
+// keep themselves. LLM Records only ever holds what went through Gateway, so
+// this is the account of an agent talking to its vendor directly - and the one
+// that has something to show before anything is routed at all.
+//
+// since bounds the window to the calendar days on or after it, formatted
+// "2006-01-02". An empty since covers every transcript that was scanned.
+func GetAgentUsage(sessions []agenthistory.Session, since string) *AgentUsage {
+       usage := &AgentUsage{
+               Agents: []AgentUsageStat{},
+               Models: []AgentUsageStat{},
+               Days:   []AgentUsageStat{},
+               Since:  since,
+       }
+       agents := map[string]*AgentUsageStat{}
+       models := map[string]*AgentUsageStat{}
+       days := map[string]*AgentUsageStat{}
+
+       for _, session := range sessions {
+               counted := false
+               for _, bucket := range session.Usage {
+                       if since != "" && bucket.Day < since {
+                               continue
+                       }
+                       counted = true
+
+                       model := bucket.Model
+                       if strings.TrimSpace(model) == "" {
+                               model = unknownModel
+                       }
+                       cost, priced := GetLlmLongCacheCost(model, 
bucket.PromptTokens, bucket.CompletionTokens,
+                               bucket.CacheWriteTokens, 
bucket.LongCacheTokens, bucket.CacheReadTokens)
+
+                       for _, stat := range []*AgentUsageStat{
+                               &usage.Totals,
+                               statOf(agents, session.Agent),
+                               statOf(models, model),
+                               statOf(days, bucket.Day),
+                       } {
+                               stat.add(bucket, cost, priced)
+                       }
+
+                       // An agent's last model is the newest one it was seen 
on, and the
+                       // buckets of a session are ordered oldest first.
+                       agent := statOf(agents, session.Agent)
+                       if session.LastTime >= agent.LastTime {
+                               agent.LastTime = session.LastTime
+                               agent.LastModel = model
+                       }
+               }
+               if counted {
+                       usage.Sessions++
+               }
+       }
+
+       usage.Agents = sorted(agents, byCost)
+       usage.Models = sorted(models, byCost)
+       usage.Days = sorted(days, byName)
+       return usage
+}
+
+func (stat *AgentUsageStat) add(bucket agenthistory.UsageBucket, cost float64, 
priced bool) {
+       stat.Requests += int64(bucket.Requests)
+       stat.PromptTokens += int64(bucket.PromptTokens)
+       stat.CompletionTokens += int64(bucket.CompletionTokens)
+       stat.CacheReadTokens += int64(bucket.CacheReadTokens)
+       stat.CacheWriteTokens += int64(bucket.CacheWriteTokens)
+       stat.ReasoningTokens += int64(bucket.ReasoningTokens)
+       stat.TotalTokens += int64(bucket.PromptTokens + bucket.CompletionTokens 
+
+               bucket.CacheReadTokens + bucket.CacheWriteTokens)
+       if priced {
+               stat.Cost += cost
+       } else {
+               stat.Unpriced += int64(bucket.Requests)
+       }
+}
+
+func statOf(stats map[string]*AgentUsageStat, name string) *AgentUsageStat {
+       stat, found := stats[name]
+       if !found {
+               stat = &AgentUsageStat{Name: name}
+               stats[name] = stat
+       }
+       return stat
+}
+
+// byCost puts the expensive first, falling back to tokens where nothing 
carries
+// a price at all, so the order does not collapse to alphabetical.
+func byCost(left, right *AgentUsageStat) bool {
+       if left.Cost != right.Cost {
+               return left.Cost > right.Cost
+       }
+       if left.TotalTokens != right.TotalTokens {
+               return left.TotalTokens > right.TotalTokens
+       }
+       return left.Name < right.Name
+}
+
+func byName(left, right *AgentUsageStat) bool {
+       return left.Name < right.Name
+}
+
+func sorted(stats map[string]*AgentUsageStat, less func(left, right 
*AgentUsageStat) bool) []AgentUsageStat {
+       ordered := make([]*AgentUsageStat, 0, len(stats))
+       for _, stat := range stats {
+               ordered = append(ordered, stat)
+       }
+       sort.SliceStable(ordered, func(left, right int) bool { return 
less(ordered[left], ordered[right]) })
+
+       result := make([]AgentUsageStat, 0, len(ordered))
+       for _, stat := range ordered {
+               result = append(result, *stat)
+       }
+       return result
+}
diff --git a/object/llm_pricing.go b/object/llm_pricing.go
index 4937c0c..1ed9cf2 100644
--- a/object/llm_pricing.go
+++ b/object/llm_pricing.go
@@ -33,31 +33,71 @@ type LlmPrice struct {
        Output     float64 `json:"output"`
        CacheWrite float64 `json:"cacheWrite"`
        CacheRead  float64 `json:"cacheRead"`
+       // CacheWrite1h is what a cache written for an hour costs instead, 
where the
+       // vendor sells the longer life at a higher rate. Zero means there is 
no such
+       // option and every write is priced at CacheWrite.
+       CacheWrite1h float64 `json:"cacheWrite1h"`
 }
 
 // builtInLlmPrices are published list prices, keyed by the part of a model 
name that
 // identifies the model. They go stale, so llmPricingFile overrides them.
+//
+// Anthropic list prices are from 
platform.claude.com/docs/en/about-claude/pricing
+// and OpenAI's from developers.openai.com/api/docs/pricing, both read on
+// 2026-09-02. A key is matched as a substring, longest first, so a family 
entry
+// covers the dated and vendor-prefixed shapes of its own name and the
+// generations that kept its price.
 var builtInLlmPrices = map[string]LlmPrice{
-       "claude-opus-4":     {15, 75, 18.75, 1.5},
-       "claude-3-opus":     {15, 75, 18.75, 1.5},
-       "claude-sonnet-4":   {3, 15, 3.75, 0.3},
-       "claude-3-7-sonnet": {3, 15, 3.75, 0.3},
-       "claude-3-5-sonnet": {3, 15, 3.75, 0.3},
-       "claude-haiku-4-5":  {1, 5, 1.25, 0.1},
-       "claude-3-5-haiku":  {0.8, 4, 1, 0.08},
-       "claude-3-haiku":    {0.25, 1.25, 0.3, 0.03},
-
-       "gpt-4o":       {2.5, 10, 0, 1.25},
-       "gpt-4o-mini":  {0.15, 0.6, 0, 0.075},
-       "gpt-4.1":      {2, 8, 0, 0.5},
-       "gpt-4.1-mini": {0.4, 1.6, 0, 0.1},
-       "gpt-4.1-nano": {0.1, 0.4, 0, 0.025},
-       "o3":           {2, 8, 0, 0.5},
-       "o3-mini":      {1.1, 4.4, 0, 0.55},
-       "o4-mini":      {1.1, 4.4, 0, 0.275},
-
-       "deepseek-chat":     {0.27, 1.1, 0, 0.07},
-       "deepseek-reasoner": {0.55, 2.19, 0, 0.14},
+       "claude-fable-5":    {10, 50, 12.5, 1, 20},
+       "claude-fable-5-1":  {10, 50, 12.5, 0.25, 20},
+       "claude-mythos-5":   {10, 50, 12.5, 1, 20},
+       "claude-mythos-5-1": {10, 50, 12.5, 0.25, 20},
+       "claude-opus-5":     {5, 25, 6.25, 0.5, 10},
+       // Opus dropped to a third of its price at 4.5; the two older ones kept 
theirs.
+       "claude-opus-4-5":   {5, 25, 6.25, 0.5, 10},
+       "claude-opus-4-6":   {5, 25, 6.25, 0.5, 10},
+       "claude-opus-4-7":   {5, 25, 6.25, 0.5, 10},
+       "claude-opus-4-8":   {5, 25, 6.25, 0.5, 10},
+       "claude-opus-4":     {15, 75, 18.75, 1.5, 30},
+       "claude-3-opus":     {15, 75, 18.75, 1.5, 30},
+       "claude-sonnet-5":   {2, 10, 2.5, 0.2, 4},
+       "claude-sonnet-4":   {3, 15, 3.75, 0.3, 6},
+       "claude-3-7-sonnet": {3, 15, 3.75, 0.3, 6},
+       "claude-3-5-sonnet": {3, 15, 3.75, 0.3, 6},
+       "claude-haiku-4-5":  {1, 5, 1.25, 0.1, 2},
+       "claude-3-5-haiku":  {0.8, 4, 1, 0.08, 1.6},
+       "claude-3-haiku":    {0.25, 1.25, 0.3, 0.03, 0.6},
+
+       "gpt-5":         {1.25, 10, 0, 0.125, 0},
+       "gpt-5-mini":    {0.25, 2, 0, 0.025, 0},
+       "gpt-5-nano":    {0.05, 0.4, 0, 0.005, 0},
+       "gpt-5-pro":     {15, 120, 0, 0, 0},
+       "gpt-5.1":       {1.25, 10, 0, 0.125, 0},
+       "gpt-5.2":       {1.75, 14, 0, 0.175, 0},
+       "gpt-5.2-pro":   {21, 168, 0, 0, 0},
+       "gpt-5.4":       {2.5, 15, 0, 0.25, 0},
+       "gpt-5.4-mini":  {0.75, 4.5, 0, 0.075, 0},
+       "gpt-5.4-nano":  {0.2, 1.25, 0, 0.02, 0},
+       "gpt-5.4-pro":   {30, 180, 0, 0, 0},
+       "gpt-5.5":       {5, 30, 0, 0.5, 0},
+       "gpt-5.5-pro":   {30, 180, 0, 0, 0},
+       "gpt-5.6-sol":   {4, 20, 0, 0.4, 0},
+       "gpt-5.6-terra": {2, 12, 0, 0.2, 0},
+       "gpt-5.6-luna":  {0.2, 1.2, 0, 0.02, 0},
+
+       "gpt-4o":       {2.5, 10, 0, 1.25, 0},
+       "gpt-4o-mini":  {0.15, 0.6, 0, 0.075, 0},
+       "gpt-4.1":      {2, 8, 0, 0.5, 0},
+       "gpt-4.1-mini": {0.4, 1.6, 0, 0.1, 0},
+       "gpt-4.1-nano": {0.1, 0.4, 0, 0.025, 0},
+       "o1":           {15, 60, 0, 7.5, 0},
+       "o3":           {2, 8, 0, 0.5, 0},
+       "o3-pro":       {20, 80, 0, 0, 0},
+       "o3-mini":      {1.1, 4.4, 0, 0.55, 0},
+       "o4-mini":      {1.1, 4.4, 0, 0.275, 0},
+
+       "deepseek-chat":     {0.27, 1.1, 0, 0.07, 0},
+       "deepseek-reasoner": {0.55, 2.19, 0, 0.14, 0},
 }
 
 var (
@@ -148,14 +188,32 @@ func GetLlmPrice(model string) (LlmPrice, bool) {
 
 // GetLlmCost is what one recorded request cost, in US dollars.
 func GetLlmCost(model string, promptTokens int, completionTokens int, 
cacheWriteTokens int, cacheReadTokens int) (float64, bool) {
+       return GetLlmLongCacheCost(model, promptTokens, completionTokens, 
cacheWriteTokens, 0, cacheReadTokens)
+}
+
+// GetLlmLongCacheCost is GetLlmCost where part of the cache was written to 
live
+// an hour rather than five minutes, which Anthropic charges 2x base input for
+// against 1.25x. longCacheTokens is the share of cacheWriteTokens written that
+// way, not an amount on top of it; a transcript says which was used, while a
+// relayed request does not, so that path passes zero and pays the shorter 
rate.
+func GetLlmLongCacheCost(model string, promptTokens int, completionTokens int, 
cacheWriteTokens int, longCacheTokens int, cacheReadTokens int) (float64, bool) 
{
        price, ok := GetLlmPrice(model)
        if !ok {
                return 0, false
        }
 
+       longCacheRate := price.CacheWrite1h
+       if longCacheRate == 0 {
+               longCacheRate = price.CacheWrite
+       }
+       if longCacheTokens > cacheWriteTokens {
+               longCacheTokens = cacheWriteTokens
+       }
+
        cost := float64(promptTokens)*price.Input +
                float64(completionTokens)*price.Output +
-               float64(cacheWriteTokens)*price.CacheWrite +
+               float64(cacheWriteTokens-longCacheTokens)*price.CacheWrite +
+               float64(longCacheTokens)*longCacheRate +
                float64(cacheReadTokens)*price.CacheRead
        return cost / 1e6, true
 }
diff --git a/routers/router.go b/routers/router.go
index 5453648..e5eac4e 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -55,6 +55,7 @@ func initAPI() {
        beego.Router("/api/get-agent-records", &controllers.ApiController{}, 
"GET:GetAgentRecords")
        beego.Router("/api/get-agent-sessions", &controllers.ApiController{}, 
"GET:GetAgentSessions")
        beego.Router("/api/get-agent-session", &controllers.ApiController{}, 
"GET:GetAgentSession")
+       beego.Router("/api/get-agent-usage", &controllers.ApiController{}, 
"GET:GetAgentUsage")
        beego.Router("/api/add-agent-record", &controllers.ApiController{}, 
"POST:AddAgentRecord")
        beego.Router("/api/get-agent-configs", &controllers.ApiController{}, 
"GET:GetAgentConfigs")
        beego.Router("/api/get-agent-config-item", 
&controllers.ApiController{}, "GET:GetAgentConfigItem")
diff --git a/web/src/backend/AgentBackend.ts b/web/src/backend/AgentBackend.ts
index 4e2b811..7c5f7f3 100644
--- a/web/src/backend/AgentBackend.ts
+++ b/web/src/backend/AgentBackend.ts
@@ -22,6 +22,7 @@ import type {
   AgentRuntime,
   AgentSession,
   AgentTranscript,
+  AgentUsage,
 } from "@/types";
 
 export interface PatchTarget {
@@ -99,6 +100,18 @@ export function getAgentSessions(agent = "") {
   return request<AgentSession[]>(`/api/get-agent-sessions${query({agent: 
agent})}`);
 }
 
+/**
+ * What the agents spent, read from the transcripts they write themselves. LLM
+ * Records only holds what went through Gateway, so this is the account of an
+ * agent talking to its vendor directly - and the one with something to show
+ * before anything is routed at all.
+ *
+ * `days` bounds the window to that many calendar days ending today.
+ */
+export function getAgentUsage(agent = "", days = 0) {
+  return request<AgentUsage>(`/api/get-agent-usage${query({agent: agent, days: 
days})}`);
+}
+
 /** The conversation inside one session, read from the agent's own transcript. 
*/
 export function getAgentSession(agent: string, session: string) {
   return request<AgentTranscript>(`/api/get-agent-session${query({agent: 
agent, session: session})}`);
diff --git a/web/src/components/AgentGridCard.tsx 
b/web/src/components/AgentGridCard.tsx
index 649f66a..cc784a4 100644
--- a/web/src/components/AgentGridCard.tsx
+++ b/web/src/components/AgentGridCard.tsx
@@ -48,6 +48,7 @@ import {formatCost, formatTokens} from "@/lib/usage";
 import type {
   Agent,
   AgentRuntime,
+  AgentUsageStat,
   LlmAgentStat,
   Provider,
   ProviderHealth,
@@ -129,6 +130,7 @@ export function AgentGridCard({
   health,
   quota,
   stats,
+  usage,
   activity,
   status,
   recording,
@@ -145,6 +147,11 @@ export function AgentGridCard({
   health: ProviderHealth[];
   quota?: ProviderQuota;
   stats?: LlmAgentStat;
+  /**
+   * What this agent's own transcripts say it spent, which counts the requests
+   * it made straight to its vendor as well as the ones Gateway relayed.
+   */
+  usage?: AgentUsageStat;
   activity?: AgentActivity;
   status?: AgentRuntime;
   /** False while llmRecordMode is off, when a zero would be a lie. */
@@ -167,6 +174,17 @@ export function AgentGridCard({
   const monitorId = monitorAgentId(agent.agentId);
   const dash = <span className="text-muted-foreground">-</span>;
   const offHint = recording ? undefined : i18next.t("llm:Recording is off");
+  // An agent's own transcript accounts for every request it made, the ones 
that
+  // never came near Gateway included, so it is the fuller of the two and wins
+  // wherever it has anything to say. The relayed totals answer for the agents
+  // that keep no transcript, and for the failures a transcript cannot show.
+  const spent = usage && usage.requests > 0 ? usage : undefined;
+  const sourceHint = spent ? i18next.t("agent:Read from the agent's own logs") 
: offHint;
+  // Off is only the truth about the relayed totals; a transcript is written
+  // whether or not Gateway is recording anything.
+  const counted = spent !== undefined || recording;
+  const lastModel = spent?.lastModel || stats?.lastModel;
+  const lastTime = spent?.lastTime || stats?.lastTime;
 
   return (
     <Card className="gap-0 py-0">
@@ -243,11 +261,11 @@ export function AgentGridCard({
 
         <div className="flex items-center justify-between gap-2">
           <span className="text-muted-foreground 
text-xs">{i18next.t("agent:Model")}</span>
-          {stats?.lastModel ? (
+          {lastModel ? (
             <SimpleTooltip
-              title={`${i18next.t("agent:Last activity")}: ${new 
Date(stats.lastTime).toLocaleString()}`}
+              title={`${i18next.t("agent:Last activity")}: ${new Date(lastTime 
?? "").toLocaleString()}`}
             >
-              <span className="min-w-0 truncate font-mono 
text-xs">{stats.lastModel}</span>
+              <span className="min-w-0 truncate font-mono 
text-xs">{lastModel}</span>
             </SimpleTooltip>
           ) : (
             <span className="text-muted-foreground text-xs">-</span>
@@ -257,9 +275,9 @@ export function AgentGridCard({
         <div className="grid grid-cols-4 gap-2 rounded-md border p-2.5">
           <Metric
             label={i18next.t("llm:Requests")}
-            value={recording ? (stats?.requests ?? 0).toLocaleString() : dash}
+            value={counted ? ((spent ?? stats)?.requests ?? 
0).toLocaleString() : dash}
             hint={
-              offHint ??
+              sourceHint ??
               (stats && stats.failed > 0
                 ? `${stats.failed.toLocaleString()} ${i18next.t("llm:failed")}`
                 : undefined)
@@ -267,13 +285,19 @@ export function AgentGridCard({
           />
           <Metric
             label={i18next.t("llm:Tokens")}
-            value={recording ? formatTokens(stats?.tokens ?? 0) : dash}
-            hint={offHint}
+            value={counted ? formatTokens(spent?.totalTokens ?? stats?.tokens 
?? 0) : dash}
+            hint={sourceHint}
           />
           <Metric
             label={i18next.t("llm:Cost")}
-            value={recording ? formatCost(stats?.cost ?? 0) : dash}
-            hint={offHint}
+            value={counted ? formatCost(spent?.cost ?? stats?.cost ?? 0) : 
dash}
+            hint={
+              spent && spent.unpriced > 0
+                ? i18next
+                  .t("llm:{count} of these requests have no list price")
+                  .replace("{count}", spent.unpriced.toLocaleString())
+                : sourceHint
+            }
           />
           <Metric
             label={i18next.t("agent:Records")}
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index 55d3b0f..0eab2bf 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -24,6 +24,8 @@ import type {
   AgentCatalogEntry,
   AgentRuntime,
   AgentSession,
+  AgentUsage,
+  AgentUsageStat,
   Provider,
 } from "@/types";
 
@@ -516,6 +518,20 @@ export function activityOf(activity: Record<string, 
AgentActivity>, agent: Agent
   return activity[monitorAgentId(agent.agentId)];
 }
 
+/**
+ * Transcripts are written per tool rather than per installation: every Codex
+ * front end shares one directory, so all of them read the same usage. That is
+ * also the limit of what the files can tell apart.
+ */
+export function usageAgentId(agentId: string) {
+  return agentId.startsWith("codex") ? "codex-cli" : agentId;
+}
+
+/** What one installation spent, out of the totals read off the transcripts. */
+export function usageOf(usage: AgentUsage | undefined, agent: Agent): 
AgentUsageStat | undefined {
+  return usage?.agents.find(item => item.name === usageAgentId(agent.agentId));
+}
+
 /** The badge colour every record table paints an outcome with. */
 export function getOutcomeVariant(outcome: string | undefined): BadgeVariant {
   return (
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index c50ffe6..ea6d031 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -112,6 +112,7 @@
     "Provider enabled": "Provider enabled",
     "Provider saved": "Provider saved",
     "Provider speaks another API": "This agent speaks the {protocol} API, 
which the bound provider does not. A direct binding needs both to speak the 
same one: pick another provider, or route this agent through the gateway, which 
translates.",
+    "Read from the agent's own logs": "Read from the agent's own logs",
     "Records": "Records",
     "Records are kept in memory only and are lost when Gateway restarts": 
"Records are kept in memory only and are lost when Gateway restarts",
     "Reported from": "Reported from",
@@ -468,6 +469,7 @@
     "tools": "tools",
     "written": "written",
     "{count} new records": "{count} new records",
+    "{count} of these requests have no list price": "{count} of these requests 
have no list price",
     "{count} records have no price": "{count} records have no price"
   },
   "provider": {
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 3174120..09c3a25 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -112,6 +112,7 @@
     "Provider enabled": "已启用供应商",
     "Provider saved": "供应商已保存",
     "Provider speaks another API": "该 Agent 使用 {protocol} 
API,而绑定的供应商不支持。直连模式要求两者使用同一种 API:请改选其他供应商,或将该 Agent 改为经网关转发(网关会自动转换)。",
+    "Read from the agent's own logs": "读自 agent 自己的日志",
     "Records": "记录数",
     "Records are kept in memory only and are lost when Gateway restarts": 
"记录仅保存在内存中,Gateway 重启后会丢失",
     "Reported from": "上报来源",
@@ -468,6 +469,7 @@
     "tools": "个工具",
     "written": "写入",
     "{count} new records": "{count} 条新记录",
+    "{count} of these requests have no list price": "其中 {count} 个请求没有对应的价目",
     "{count} records have no price": "{count} 条记录没有价格"
   },
   "provider": {
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index 254d417..32b1706 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -17,6 +17,7 @@ import {Link} from "react-router-dom";
 import {Bot, Container, Plus, RefreshCw, Table2} from "lucide-react";
 import i18next from "i18next";
 
+import * as AgentBackend from "@/backend/AgentBackend";
 import * as LlmRecordBackend from "@/backend/LlmRecordBackend";
 import * as ProviderBackend from "@/backend/ProviderBackend";
 import * as Setting from "@/Setting";
@@ -29,12 +30,26 @@ import {PageContainer, PageHeader} from 
"@/components/shared/page-header";
 import {MessageAlert} from "@/components/ui/alert";
 import {Button} from "@/components/ui/button";
 import {Card} from "@/components/ui/card";
-import {activityOf, agentKey, runtimeOf, useAgents, useAgentSessions} from 
"@/lib/agents";
-import type {Account, LlmAgentStat, Provider, ProviderHealth, ProviderQuota} 
from "@/types";
+import {activityOf, agentKey, runtimeOf, useAgents, useAgentSessions, usageOf} 
from "@/lib/agents";
+import type {
+  Account,
+  AgentUsage,
+  LlmAgentStat,
+  Provider,
+  ProviderHealth,
+  ProviderQuota,
+} from "@/types";
 
 /** How often the numbers on the cards are read again. */
 const refreshMs = 10000;
 
+/**
+ * How often the transcripts are added up again. Reading them walks every
+ * session file on the machine, so it is asked for far less often than the
+ * counters the proxy keeps in memory.
+ */
+const usageRefreshMs = 60000;
+
 /**
  * The home screen: one card per agent installed on this machine. Each card
  * carries what that agent is on, what it has spent there and whether it is
@@ -63,6 +78,7 @@ export default function HomePage({account}: {account: 
Account}) {
   const [health, setHealth] = React.useState<ProviderHealth[]>([]);
   const [quotas, setQuotas] = React.useState<ProviderQuota[]>([]);
   const [stats, setStats] = React.useState<LlmAgentStat[]>([]);
+  const [usage, setUsage] = React.useState<AgentUsage>();
   // Nothing is recorded until it is asked for, and until it is a zero on a 
card
   // would read as "this agent relayed nothing" rather than "nobody counted".
   const [recording, setRecording] = React.useState(true);
@@ -137,6 +153,25 @@ export default function HomePage({account}: {account: 
Account}) {
     return () => clearInterval(interval);
   }, [isAdmin]);
 
+  // What the agents wrote down themselves, which is the only account of the
+  // requests that never went through Gateway - and the only one there is at 
all
+  // before a provider is bound.
+  React.useEffect(() => {
+    if (!isAdmin) {
+      return undefined;
+    }
+
+    const load = () => {
+      AgentBackend.getAgentUsage()
+        .then(res => setUsage(res.status === "ok" ? (res.data ?? undefined) : 
undefined))
+        .catch(() => undefined);
+    };
+
+    load();
+    const interval = setInterval(load, usageRefreshMs);
+    return () => clearInterval(interval);
+  }, [isAdmin]);
+
   if (!isAdmin) {
     return <UnauthorizedResult />;
   }
@@ -235,6 +270,7 @@ export default function HomePage({account}: {account: 
Account}) {
               health={health}
               quota={quotas.find(item => item.provider === agent.provider)}
               stats={stats.find(item => item.agent === agent.agentId)}
+              usage={usageOf(usage, agent)}
               activity={activityOf(activity, agent)}
               status={runtimeOf(runtime, agent)}
               recording={recording}
diff --git a/web/src/types.ts b/web/src/types.ts
index 338f2e3..45312ca 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -353,6 +353,46 @@ export interface LlmAgentStat {
   lastProvider: string;
 }
 
+/**
+ * What one agent, one model or one day spent, read from the transcripts the
+ * agents keep themselves. Which of the three a stat counts is decided by the
+ * list it is in; `name` is that key.
+ */
+export interface AgentUsageStat {
+  name: string;
+  requests: number;
+  promptTokens: number;
+  completionTokens: number;
+  cacheReadTokens: number;
+  cacheWriteTokens: number;
+  reasoningTokens: number;
+  totalTokens: number;
+  cost: number;
+  /** Requests left out of `cost` because no list price matched their model. */
+  unpriced: number;
+  /** Filled for agents only, which is what the card beside the totals shows. 
*/
+  lastTime: string;
+  lastModel: string;
+}
+
+/**
+ * What the agents on this machine spent, whether or not Gateway relayed any of
+ * it. LLM Records is empty until an agent is routed through the gateway, and
+ * stays empty for one talking to its vendor directly; this is read off the
+ * agent's own transcripts, so it counts either way.
+ */
+export interface AgentUsage {
+  totals: AgentUsageStat;
+  agents: AgentUsageStat[];
+  models: AgentUsageStat[];
+  /** Oldest first, which is the order a trend is drawn in. */
+  days: AgentUsageStat[];
+  /** How many transcripts the totals were read from. */
+  sessions: number;
+  /** The first day the totals cover, or empty when they cover everything. */
+  since: string;
+}
+
 /** The settings the web UI can change, so nobody has to edit conf/app.conf. */
 /**
  * The one built-in row of the Setting table, holding everything that used to 
be


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

Reply via email to