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]