diff --git a/docs/evidence/usage-statistics/usage-dark.png b/docs/evidence/usage-statistics/usage-dark.png
new file mode 100644
index 000000000..63ed12a76
Binary files /dev/null and b/docs/evidence/usage-statistics/usage-dark.png differ
diff --git a/docs/evidence/usage-statistics/usage-mobile.png b/docs/evidence/usage-statistics/usage-mobile.png
new file mode 100644
index 000000000..c77575524
Binary files /dev/null and b/docs/evidence/usage-statistics/usage-mobile.png differ
diff --git a/docs/openapi.json b/docs/openapi.json
index 3808fe488..2356f74d4 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -33554,6 +33554,100 @@
"x-cyberagent-read-only": false
}
},
+ "/api/v1/usage": {
+ "get": {
+ "operationId": "getUsageStatistics",
+ "summary": "Inspect local application usage",
+ "description": "Aggregates this application's durable model terminal receipts across root agents, specialists, context compaction, and read-only fan-out; failed sent calls count and not-sent receipts are excluded. Tokens, model groups, recent Threads, sparse days, and untracked_calls use terminal receipt time. Cache tokens are input subsets; usage_reported_calls and cache_usage_reported_calls identify missing coverage. Missing provider/model identities remain empty strings. Costs use reservation settled_at independently of receipt time: known_cost_micros sums only actual priced USD settlements, unpriced_calls counts unknown-cost settlements, and untracked_calls counts receipts without a matched settlement. Reservation estimates never become known cost. Calendar dates use a fixed UTC offset; 30d includes today and the preceding 29 dates, all starts at the earliest receipt or settlement, and empty history starts today. The sparse activity_days always cover the last 182 calendar dates including today, independently of period. Collections are sorted deterministically; recent_sessions includes at most eight Threads with selected-period receipts and uses their latest receipt as last_activity_at. Unbound historical Runs contribute to totals and unassigned_calls.",
+ "tags": [
+ "System"
+ ],
+ "parameters": [
+ {
+ "name": "period",
+ "in": "query",
+ "description": "Calendar reporting period",
+ "schema": {
+ "default": "30d",
+ "enum": [
+ "today",
+ "30d",
+ "all"
+ ],
+ "type": "string"
+ }
+ },
+ {
+ "name": "utc_offset_minutes",
+ "in": "query",
+ "description": "Fixed UTC offset in minutes, without daylight-saving transitions",
+ "schema": {
+ "default": 0,
+ "maximum": 840,
+ "minimum": -840,
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful read",
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": false,
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/UsageStatistics"
+ },
+ "request_id": {
+ "maxLength": 256,
+ "minLength": 1,
+ "type": "string"
+ },
+ "version": {
+ "const": "api.v1",
+ "type": "string"
+ }
+ },
+ "required": [
+ "version",
+ "request_id",
+ "data"
+ ],
+ "type": "object"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ },
+ "414": {
+ "$ref": "#/components/responses/RequestTooLarge"
+ },
+ "429": {
+ "$ref": "#/components/responses/ResourceExhausted"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalError"
+ },
+ "503": {
+ "$ref": "#/components/responses/Unavailable"
+ },
+ "504": {
+ "$ref": "#/components/responses/GatewayTimeout"
+ }
+ },
+ "x-cyberagent-read-only": true
+ }
+ },
"/api/v1/work-items/{work_item_id}": {
"get": {
"operationId": "getWorkItem",
@@ -72725,6 +72819,250 @@
],
"type": "object"
},
+ "UsageActivityDay": {
+ "additionalProperties": false,
+ "properties": {
+ "calls": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "date": {
+ "type": "string"
+ },
+ "total_tokens": {
+ "format": "int64",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "date",
+ "calls",
+ "total_tokens"
+ ],
+ "type": "object"
+ },
+ "UsageCost": {
+ "additionalProperties": false,
+ "properties": {
+ "currency": {
+ "type": "string"
+ },
+ "known_cost_micros": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "priced_calls": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "unpriced_calls": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "untracked_calls": {
+ "format": "int64",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "currency",
+ "known_cost_micros",
+ "priced_calls",
+ "unpriced_calls",
+ "untracked_calls"
+ ],
+ "type": "object"
+ },
+ "UsageDay": {
+ "additionalProperties": false,
+ "properties": {
+ "date": {
+ "type": "string"
+ },
+ "usage": {
+ "$ref": "#/components/schemas/UsageTotals"
+ }
+ },
+ "required": [
+ "date",
+ "usage"
+ ],
+ "type": "object"
+ },
+ "UsageModel": {
+ "additionalProperties": false,
+ "properties": {
+ "model": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ },
+ "usage": {
+ "$ref": "#/components/schemas/UsageTotals"
+ }
+ },
+ "required": [
+ "provider",
+ "model",
+ "usage"
+ ],
+ "type": "object"
+ },
+ "UsageSession": {
+ "additionalProperties": false,
+ "properties": {
+ "last_activity_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "thread_id": {
+ "maxLength": 256,
+ "minLength": 1,
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "usage": {
+ "$ref": "#/components/schemas/UsageTotals"
+ }
+ },
+ "required": [
+ "thread_id",
+ "title",
+ "last_activity_at",
+ "usage"
+ ],
+ "type": "object"
+ },
+ "UsageStatistics": {
+ "additionalProperties": false,
+ "properties": {
+ "activity_days": {
+ "items": {
+ "$ref": "#/components/schemas/UsageActivityDay"
+ },
+ "type": "array"
+ },
+ "activity_end_date": {
+ "type": "string"
+ },
+ "activity_start_date": {
+ "type": "string"
+ },
+ "cost": {
+ "$ref": "#/components/schemas/UsageCost"
+ },
+ "days": {
+ "items": {
+ "$ref": "#/components/schemas/UsageDay"
+ },
+ "type": "array"
+ },
+ "end_date": {
+ "type": "string"
+ },
+ "generated_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "models": {
+ "items": {
+ "$ref": "#/components/schemas/UsageModel"
+ },
+ "type": "array"
+ },
+ "period": {
+ "type": "string"
+ },
+ "recent_sessions": {
+ "items": {
+ "$ref": "#/components/schemas/UsageSession"
+ },
+ "type": "array"
+ },
+ "start_date": {
+ "type": "string"
+ },
+ "totals": {
+ "$ref": "#/components/schemas/UsageTotals"
+ },
+ "unassigned_calls": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "utc_offset_minutes": {
+ "format": "int32",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "generated_at",
+ "period",
+ "utc_offset_minutes",
+ "start_date",
+ "end_date",
+ "totals",
+ "cost",
+ "unassigned_calls",
+ "models",
+ "recent_sessions",
+ "days",
+ "activity_start_date",
+ "activity_end_date",
+ "activity_days"
+ ],
+ "type": "object"
+ },
+ "UsageTotals": {
+ "additionalProperties": false,
+ "properties": {
+ "cache_creation_tokens": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "cache_read_tokens": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "cache_usage_reported_calls": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "calls": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "input_tokens": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "output_tokens": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "total_tokens": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "usage_reported_calls": {
+ "format": "int64",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "input_tokens",
+ "output_tokens",
+ "total_tokens",
+ "cache_read_tokens",
+ "cache_creation_tokens",
+ "calls",
+ "usage_reported_calls",
+ "cache_usage_reported_calls"
+ ],
+ "type": "object"
+ },
"VerificationAssociationControlView": {
"additionalProperties": false,
"properties": {
diff --git a/docs/usage.md b/docs/usage.md
index b2f6eec96..d8b814538 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -40,6 +40,34 @@ false until that matrix is signed.
## Threads, Runs, and advanced diagnostics / Thread、Run 与高级诊断
+### 用量统计 / Usage
+
+在「设置 → 用量统计」查看 Universal-Code 自身的模型调用。今日、近 30 天和全部
+历史可切换 Token 总量、输入/输出/缓存明细、趋势、模型占比和最近八个会话;点击
+会话标题可回到对话。活动热图固定展示最近 26 周;悬停、点击或方向键选中格子时,
+白色边框标出当前日期,浮层显示日期和 Token 数,按 Esc 可关闭。页面沿用当前主题
+和 Mono 字体,中文与 English 即时切换。
+
+[深色页面](evidence/usage-statistics/usage-dark.png)和
+[窄屏页面](evidence/usage-statistics/usage-mobile.png)来自实际浏览器;截图中的用量为
+演示数据,用于检查图表与排版。
+
+Token 按持久化调用完成时间统计,包含主代理、子代理、并行调用、上下文压缩及
+已经产生用量的失败调用。缓存是输入 Token 的一部分;没有返回的用量和缓存明细
+会明确标注。日期按页面显示的当前 UTC 偏移划分,页面打开时每分钟更新。
+
+费用按实际结算时间统计,只展示已有完整价格记录的本地 USD 估算,并分别注明
+已定价、缺少完整计价和未记录费用的调用。未启用费用记录的历史调用不能据此推算
+真实账单。跨日结算与 Token 的日期可能不同,实际收费以供应商账单为准。
+
+`GET /api/v1/usage?period=30d&utc_offset_minutes=480` uses existing read access.
+`period` accepts `today`, `30d`, or `all`; the fixed UTC offset is in minutes.
+Only this application's durable local records are aggregated. The endpoint does
+not contact providers or inspect other applications' logs. Model token totals
+include all agent roles; cache tokens are already included in input. Costs use
+priced settlements, independently dated from call receipts. Missing usage and
+pricing remain visible through coverage counts.
+
### Markdown 输入与复制
Web/Desktop 的输入框默认显示 Markdown 格式,支持标题、粗体、引用、列表、任务列表、
diff --git a/internal/domain/usage_statistics.go b/internal/domain/usage_statistics.go
new file mode 100644
index 000000000..5f2191662
--- /dev/null
+++ b/internal/domain/usage_statistics.go
@@ -0,0 +1,70 @@
+package domain
+
+import "time"
+
+// UsageStatistics is a read-only projection of this application's local model
+// receipts. Calendar dates use the requested fixed UTC offset.
+type UsageStatistics struct {
+ GeneratedAt time.Time `json:"generated_at"`
+ Period string `json:"period"`
+ UTCOffsetMinutes int `json:"utc_offset_minutes"`
+ StartDate string `json:"start_date"`
+ EndDate string `json:"end_date"`
+ Totals UsageTotals `json:"totals"`
+ Cost UsageCost `json:"cost"`
+ UnassignedCalls int64 `json:"unassigned_calls"`
+ Models []UsageModel `json:"models"`
+ RecentSessions []UsageSession `json:"recent_sessions"`
+ Days []UsageDay `json:"days"`
+ ActivityStartDate string `json:"activity_start_date"`
+ ActivityEndDate string `json:"activity_end_date"`
+ ActivityDays []UsageActivityDay `json:"activity_days"`
+}
+
+// Cache tokens are subsets of input tokens. Coverage counts distinguish absent
+// receipts/cache details from explicitly reported zero usage.
+type UsageTotals struct {
+ InputTokens int64 `json:"input_tokens"`
+ OutputTokens int64 `json:"output_tokens"`
+ TotalTokens int64 `json:"total_tokens"`
+ CacheReadTokens int64 `json:"cache_read_tokens"`
+ CacheCreationTokens int64 `json:"cache_creation_tokens"`
+ Calls int64 `json:"calls"`
+ UsageReportedCalls int64 `json:"usage_reported_calls"`
+ CacheUsageReportedCalls int64 `json:"cache_usage_reported_calls"`
+}
+
+// KnownCostMicros sums only actual priced USD settlements in the settlement
+// period. UntrackedCalls counts receipts in the receipt period without a settled
+// reservation; the three counts can therefore describe different calendar days.
+type UsageCost struct {
+ Currency string `json:"currency"`
+ KnownCostMicros int64 `json:"known_cost_micros"`
+ PricedCalls int64 `json:"priced_calls"`
+ UnpricedCalls int64 `json:"unpriced_calls"`
+ UntrackedCalls int64 `json:"untracked_calls"`
+}
+
+type UsageModel struct {
+ Provider string `json:"provider"`
+ Model string `json:"model"`
+ Usage UsageTotals `json:"usage"`
+}
+
+type UsageSession struct {
+ ThreadID string `json:"thread_id"`
+ Title string `json:"title"`
+ LastActivityAt time.Time `json:"last_activity_at"`
+ Usage UsageTotals `json:"usage"`
+}
+
+type UsageDay struct {
+ Date string `json:"date"`
+ Usage UsageTotals `json:"usage"`
+}
+
+type UsageActivityDay struct {
+ Date string `json:"date"`
+ Calls int64 `json:"calls"`
+ TotalTokens int64 `json:"total_tokens"`
+}
diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go
index ece9d791d..a4d196503 100644
--- a/internal/httpapi/handlers.go
+++ b/internal/httpapi/handlers.go
@@ -57,7 +57,7 @@ func (a *API) route(request *http.Request) (any, *Page, error) {
if err := rejectQuery(request.URL.Query()); err != nil {
return nil, nil, err
}
- resources := []string{"threads", "runs", "sessions", "work-items", "notes", "artifacts",
+ resources := []string{"usage", "threads", "runs", "sessions", "work-items", "notes", "artifacts",
"agent-graph", "delegations", "readonly-fanout", "finding-reports",
"external-skills", "workspaces", "workspace-explorer", "workspace-search", "models",
"project-instructions", "long-term-memories", "session-tree",
@@ -182,6 +182,8 @@ func (a *API) route(request *http.Request) (any, *Page, error) {
return nil, nil, err
}
return a.health(request)
+ case "/api/v1/usage":
+ return a.usageStatistics(request)
case "/api/v1/capabilities":
return a.runtimeCapabilities(request)
case ExtensionInventoryPath:
diff --git a/internal/httpapi/openapi.go b/internal/httpapi/openapi.go
index 246ab724c..e3021d493 100644
--- a/internal/httpapi/openapi.go
+++ b/internal/httpapi/openapi.go
@@ -2286,6 +2286,12 @@ func openAPIOperationSpecs() []openAPIOperationSpec {
Description: "Returns persisted Supervisor tool rounds with the same Go-owned closed thread_activity_detail.v2 union used by the Thread conversation. Raw durable payload/result JSON and generic name/value fact arrays are never exposed; unsupported historical calls fail closed with detail_available=false.",
DataType: reflect.TypeOf(SupervisorToolRoundView{}), Collection: true, Paginated: true,
NotFound: true, Parameters: append([]openAPIParameter{runID}, paginationParameters()...)},
+ {Path: "/api/v1/usage", OperationID: "getUsageStatistics", Summary: "Inspect local application usage", Tag: "System",
+ Description: "Aggregates this application's durable model terminal receipts across root agents, specialists, context compaction, and read-only fan-out; failed sent calls count and not-sent receipts are excluded. Tokens, model groups, recent Threads, sparse days, and untracked_calls use terminal receipt time. Cache tokens are input subsets; usage_reported_calls and cache_usage_reported_calls identify missing coverage. Missing provider/model identities remain empty strings. Costs use reservation settled_at independently of receipt time: known_cost_micros sums only actual priced USD settlements, unpriced_calls counts unknown-cost settlements, and untracked_calls counts receipts without a matched settlement. Reservation estimates never become known cost. Calendar dates use a fixed UTC offset; 30d includes today and the preceding 29 dates, all starts at the earliest receipt or settlement, and empty history starts today. The sparse activity_days always cover the last 182 calendar dates including today, independently of period. Collections are sorted deterministically; recent_sessions includes at most eight Threads with selected-period receipts and uses their latest receipt as last_activity_at. Unbound historical Runs contribute to totals and unassigned_calls.",
+ DataType: reflect.TypeOf(domain.UsageStatistics{}), Parameters: []openAPIParameter{
+ {Name: "period", In: "query", Description: "Calendar reporting period", Schema: map[string]any{"type": "string", "enum": []string{"today", "30d", "all"}, "default": "30d"}},
+ {Name: "utc_offset_minutes", In: "query", Description: "Fixed UTC offset in minutes, without daylight-saving transitions", Schema: map[string]any{"type": "integer", "minimum": -840, "maximum": 840, "default": 0}},
+ }},
{Path: "/api/v1/sessions", OperationID: "listSessions", Summary: "List Sessions", Tag: "Sessions",
Description: "Returns a bounded creation-ordered keyset page of persisted Sessions. Updates and later Sessions cannot shift continuation pages.", DataType: reflect.TypeOf(SessionView{}),
Collection: true, Paginated: true, Parameters: paginationParameters()},
diff --git a/internal/httpapi/usage_statistics.go b/internal/httpapi/usage_statistics.go
new file mode 100644
index 000000000..5c62cf04b
--- /dev/null
+++ b/internal/httpapi/usage_statistics.go
@@ -0,0 +1,50 @@
+package httpapi
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+ "time"
+
+ "cyberagent-workbench/internal/apperror"
+ "cyberagent-workbench/internal/domain"
+ "cyberagent-workbench/internal/redact"
+)
+
+func (a *API) usageStatistics(request *http.Request) (any, *Page, error) {
+ values := request.URL.Query()
+ for name, items := range values {
+ if (name != "period" && name != "utc_offset_minutes") || len(items) != 1 || items[0] == "" {
+ return nil, nil, apperror.New(apperror.CodeInvalidArgument, "usage query parameter is invalid")
+ }
+ }
+ period := values.Get("period")
+ if period == "" {
+ period = "30d"
+ }
+ if period != "today" && period != "30d" && period != "all" {
+ return nil, nil, apperror.New(apperror.CodeInvalidArgument, "usage period must be today, 30d, or all")
+ }
+ offset := 0
+ if raw := values.Get("utc_offset_minutes"); raw != "" {
+ var err error
+ offset, err = strconv.Atoi(raw)
+ if err != nil || offset < -840 || offset > 840 {
+ return nil, nil, apperror.New(apperror.CodeInvalidArgument, "usage UTC offset must be an integer between -840 and 840")
+ }
+ }
+ source, ok := a.store.(interface {
+ GetUsageStatistics(context.Context, string, int, time.Time) (domain.UsageStatistics, error)
+ })
+ if !ok {
+ return nil, nil, apperror.New(apperror.CodeFailedPrecondition, "usage statistics are unavailable")
+ }
+ result, err := source.GetUsageStatistics(request.Context(), period, offset, time.Now().UTC())
+ if err != nil {
+ return nil, nil, err
+ }
+ for i := range result.RecentSessions {
+ result.RecentSessions[i].Title = redact.String(result.RecentSessions[i].Title)
+ }
+ return result, nil, nil
+}
diff --git a/internal/httpapi/usage_statistics_test.go b/internal/httpapi/usage_statistics_test.go
new file mode 100644
index 000000000..d0c6c01ae
--- /dev/null
+++ b/internal/httpapi/usage_statistics_test.go
@@ -0,0 +1,78 @@
+package httpapi
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "cyberagent-workbench/internal/domain"
+ "cyberagent-workbench/internal/store"
+)
+
+func TestUsageStatisticsReadAPIQueryAndAuthorization(t *testing.T) {
+ st, err := store.Open(filepath.Join(t.TempDir(), "usage.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer st.Close()
+ api, err := New(st, Config{AccessToken: testAccessToken, ControlToken: testControlToken})
+ if err != nil {
+ t.Fatal(err)
+ }
+ request := func(method, path, token string) *httptest.ResponseRecorder {
+ return performRequest(t, api, method, path, token, "127.0.0.1", "127.0.0.1:12345", nil)
+ }
+ for _, token := range []string{"", testControlToken} {
+ response := request(http.MethodGet, "/api/v1/usage", token)
+ if response.Code != http.StatusUnauthorized {
+ t.Fatalf("usage exposed without read authorization: %+v", response)
+ }
+ }
+ response := performRequest(t, api, http.MethodGet, "/api/v1/usage", testAccessToken, "127.0.0.1", "127.0.0.1:12345", nil)
+ var result domain.UsageStatistics
+ decodeData(t, response, &result)
+ if result.Period != "30d" || result.UTCOffsetMinutes != 0 || result.Cost.Currency != "USD" || result.Models == nil || result.RecentSessions == nil {
+ t.Fatalf("wrong defaults/empty collections: %+v", result)
+ }
+ response = performRequest(t, api, http.MethodGet, "/api/v1/usage?period=all&utc_offset_minutes=480", testAccessToken, "127.0.0.1", "127.0.0.1:12345", nil)
+ decodeData(t, response, &result)
+ if result.Period != "all" || result.UTCOffsetMinutes != 480 {
+ t.Fatalf("usage query not forwarded: %+v", result)
+ }
+ for _, query := range []string{"period=week", "period=", "period=today&period=all", "utc_offset_minutes=", "utc_offset_minutes=841", "utc_offset_minutes=-841", "utc_offset_minutes=1.5", "utc_offset_minutes=0&utc_offset_minutes=1", "unknown=all"} {
+ response := request(http.MethodGet, "/api/v1/usage?"+query, testAccessToken)
+ if response.Code != http.StatusBadRequest {
+ t.Fatalf("invalid query accepted: %s: %+v", query, response)
+ }
+ }
+ if response := request(http.MethodPost, "/api/v1/usage", testAccessToken); response.Code != http.StatusMethodNotAllowed {
+ t.Fatalf("usage accepted a mutation: %+v", response)
+ }
+ withBody := performRequest(t, api, http.MethodGet, "/api/v1/usage", testAccessToken, "127.0.0.1", "127.0.0.1:12345", strings.NewReader("{}"))
+ if withBody.Code != http.StatusBadRequest {
+ t.Fatalf("usage accepted a GET body: %d %s", withBody.Code, withBody.Body.String())
+ }
+}
+
+func TestUsageStatisticsOpenAPIContract(t *testing.T) {
+ raw, err := GenerateOpenAPI()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var spec openAPIDocument
+ if err := json.Unmarshal(raw, &spec); err != nil {
+ t.Fatal(err)
+ }
+ operation := spec.Paths["/api/v1/usage"].Get
+ if operation == nil || !operation.ReadOnly || operation.OperationID != "getUsageStatistics" || len(operation.Parameters) != 2 {
+ t.Fatalf("usage contract missing: %+v", operation)
+ }
+ for _, name := range []string{"UsageStatistics", "UsageTotals", "UsageCost", "UsageModel", "UsageSession", "UsageDay", "UsageActivityDay"} {
+ if spec.Components.Schemas[name] == nil {
+ t.Fatalf("usage response type %s absent from OpenAPI", name)
+ }
+ }
+}
diff --git a/internal/runner/controlled_command.go b/internal/runner/controlled_command.go
index 2cfa699a2..cb86108db 100644
--- a/internal/runner/controlled_command.go
+++ b/internal/runner/controlled_command.go
@@ -15,12 +15,14 @@ import (
)
const (
- ControlledCommandPlanProtocolVersion = "controlled_command_plan.v1"
- ControlledCommandPolicyVersion = "controlled_command_policy.v1"
- DefaultControlledCommandTimeout = 30 * time.Second
- MaxControlledCommandTimeout = 2 * time.Minute
- MaxControlledRelativePathRunes = 512
- controlledPowerShellWorkspaceListScript = `& { param([string]$RelativePathHex) if ($RelativePathHex -notmatch '\Ah[0-9a-f]+\z' -or (($RelativePathHex.Length - 1) % 2) -ne 0) { throw 'invalid relative path' }; $Hex = $RelativePathHex.Substring(1); $Bytes = [byte[]]@(for ($Index = 0; $Index -lt $Hex.Length; $Index += 2) { [Convert]::ToByte($Hex.Substring($Index, 2), 16) }); $RelativePath = [Text.Encoding]::UTF8.GetString($Bytes); Get-ChildItem -LiteralPath $RelativePath -Force | Select-Object Name,Length,Attributes | ConvertTo-Json -Compress }`
+ ControlledCommandPlanProtocolVersion = "controlled_command_plan.v1"
+ ControlledCommandPolicyVersion = "controlled_command_policy.v1"
+ DefaultControlledCommandTimeout = 30 * time.Second
+ MaxControlledCommandTimeout = 2 * time.Minute
+ MaxControlledRelativePathRunes = 512
+ // Load the known system manifests directly before cmdlet discovery can
+ // analyze available binary modules in Windows PowerShell 5.
+ controlledPowerShellWorkspaceListScript = `& { param([string]$RelativePathHex) if ($RelativePathHex -notmatch '\Ah[0-9a-f]+\z' -or (($RelativePathHex.Length - 1) % 2) -ne 0) { throw 'invalid relative path' }; $Hex = $RelativePathHex.Substring(1); $Bytes = [byte[]]@(for ($Index = 0; $Index -lt $Hex.Length; $Index += 2) { [Convert]::ToByte($Hex.Substring($Index, 2), 16) }); $RelativePath = [Text.Encoding]::UTF8.GetString($Bytes); Microsoft.PowerShell.Core\Import-Module -Name ($PSHOME + '\Modules\Microsoft.PowerShell.Management\Microsoft.PowerShell.Management.psd1'),($PSHOME + '\Modules\Microsoft.PowerShell.Utility\Microsoft.PowerShell.Utility.psd1') -ErrorAction Stop; Get-ChildItem -LiteralPath $RelativePath -Force | Select-Object Name,Length,Attributes | ConvertTo-Json -Compress }`
)
var ErrControlledCommandBoundary = errors.New(
diff --git a/internal/runner/controlled_execution_windows_test.go b/internal/runner/controlled_execution_windows_test.go
index e2961ca46..97bdd88bc 100644
--- a/internal/runner/controlled_execution_windows_test.go
+++ b/internal/runner/controlled_execution_windows_test.go
@@ -126,6 +126,24 @@ func TestWindowsFixedCommandRuntimeUsesRestrictedNativeProcess(t *testing.T) {
if !bytes.Contains(result.stdout.value, []byte(want)) {
t.Fatalf("fixed output missing %q: %q", want, result.stdout.value)
}
+ if kind == ControlledCommandPowerShellWorkspaceList {
+ var listing struct {
+ Name string
+ Length int64
+ Attributes uint32
+ }
+ if err := json.Unmarshal(result.stdout.value, &listing); err != nil || listing.Name != want || listing.Length != int64(len("marker")) {
+ t.Fatalf("fixed listing changed: %q, error=%v", result.stdout.value, err)
+ }
+ markerPath, err := windows.UTF16PtrFromString(filepath.Join(request.WorkspaceRoot, want))
+ if err != nil {
+ t.Fatal(err)
+ }
+ attributes, err := windows.GetFileAttributes(markerPath)
+ if err != nil || listing.Attributes != attributes {
+ t.Fatalf("fixed listing attributes=%d want=%d error=%v", listing.Attributes, attributes, err)
+ }
+ }
if reaped, err := waitControlledJobReaped(t.Context(), native.job, time.Second); err != nil || !reaped {
t.Fatal("fixed process tree remained", reaped, err)
}
diff --git a/internal/store/usage_statistics.go b/internal/store/usage_statistics.go
new file mode 100644
index 000000000..174f84b00
--- /dev/null
+++ b/internal/store/usage_statistics.go
@@ -0,0 +1,425 @@
+package store
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "math"
+ "sort"
+ "time"
+
+ "cyberagent-workbench/internal/apperror"
+ "cyberagent-workbench/internal/domain"
+ "cyberagent-workbench/internal/llm"
+)
+
+type usageReceipt struct {
+ runID, source, subject, kind, threadID, title string
+ sequence, compactionID int64
+ at time.Time
+ legacyUsage *llm.Usage
+ payload struct {
+ Provider string `json:"provider"`
+ Model string `json:"model"`
+ Dispatch string `json:"dispatch"`
+ ErrorCode string `json:"error_code"`
+ Usage *llm.Usage `json:"usage"`
+ UsageUnknown bool `json:"usage_unknown"`
+ InputTokens *int64 `json:"input_tokens"`
+ OutputTokens *int64 `json:"output_tokens"`
+ TotalTokens *int64 `json:"total_tokens"`
+ MonetaryKey int64 `json:"monetary_attempt_number"`
+ ModelAttempt int64 `json:"model_attempt"`
+ Purpose string `json:"purpose"`
+ ExecutionID string `json:"execution_id"`
+ Shard int `json:"shard"`
+ FanoutAttempt int `json:"attempt"`
+ }
+}
+
+func (r usageReceipt) callKey() string {
+ // A fan-out shard has multiple transport attempts with the same subject.
+ subject := r.subject
+ if subject == "" {
+ subject = fmt.Sprintf("sequence:%d", r.sequence)
+ }
+ return fmt.Sprintf("%s/%s/%s/%d", r.runID, r.source, subject, r.payload.FanoutAttempt)
+}
+
+func (r usageReceipt) monetaryIdentity() (string, int64) {
+ switch r.source {
+ case "specialist_model_gateway":
+ return domain.MonetaryScopeSpecialist, r.payload.MonetaryKey
+ case "thread_context_compaction":
+ return domain.MonetaryScopeContextCompaction, r.compactionID
+ case "readonly_fanout":
+ if r.payload.ExecutionID == "" || r.payload.FanoutAttempt <= 0 {
+ return domain.MonetaryScopeReadOnlyFanout, 0
+ }
+ return domain.MonetaryScopeReadOnlyFanout, int64(llm.ReadOnlyFanoutMonetaryAttemptNumber(r.payload.ExecutionID, r.payload.Shard, r.payload.FanoutAttempt))
+ default:
+ return domain.MonetaryScopeRoot, r.payload.MonetaryKey
+ }
+}
+
+// GetUsageStatistics reads a consistent local ledger without settling, repairing,
+// or acquiring a writer lock. Terminal receipts alone contribute tokens, so
+// checkpoints, started events, and monetary ledgers cannot double count usage.
+func (s *SQLiteStore) GetUsageStatistics(ctx context.Context, period string, utcOffsetMinutes int, now time.Time) (domain.UsageStatistics, error) {
+ if period == "" {
+ period = "30d"
+ }
+ if (period != "today" && period != "30d" && period != "all") || utcOffsetMinutes < -840 || utcOffsetMinutes > 840 || now.IsZero() {
+ return domain.UsageStatistics{}, apperror.New(apperror.CodeInvalidArgument, "usage period or UTC offset is invalid")
+ }
+ location := time.FixedZone("usage", utcOffsetMinutes*60)
+ local := now.In(location)
+ today := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
+ start := today
+ if period == "30d" {
+ start = today.AddDate(0, 0, -29)
+ }
+ activityStart := today.AddDate(0, 0, -181)
+ result := domain.UsageStatistics{GeneratedAt: now.UTC(), Period: period, UTCOffsetMinutes: utcOffsetMinutes,
+ StartDate: start.Format(time.DateOnly), EndDate: today.Format(time.DateOnly),
+ Cost: domain.UsageCost{Currency: "USD"}, Models: []domain.UsageModel{}, RecentSessions: []domain.UsageSession{},
+ Days: []domain.UsageDay{}, ActivityStartDate: activityStart.Format(time.DateOnly), ActivityEndDate: today.Format(time.DateOnly),
+ ActivityDays: []domain.UsageActivityDay{}}
+ tx, finish, err := s.beginThreadRequestObservation(ctx)
+ if err != nil {
+ return result, err
+ }
+ defer finish()
+ rows, err := tx.QueryContext(ctx, `SELECT e.run_id,e.sequence,e.source,e.type,COALESCE(e.subject_id,''),e.created_at,e.payload_json,
+ COALESCE(t.id,''),COALESCE(t.title,''),COALESCE(c.id,0),
+ CASE WHEN f.usage_recorded=1 THEN f.input_tokens END,
+ CASE WHEN f.usage_recorded=1 THEN f.output_tokens END,
+ CASE WHEN f.usage_recorded=1 THEN f.total_tokens END
+ FROM run_events e LEFT JOIN thread_runs binding ON binding.run_id=e.run_id
+ LEFT JOIN threads t ON t.id=binding.thread_id
+ LEFT JOIN thread_context_compactions c ON e.source='thread_context_compaction' AND c.run_id=e.run_id AND c.operation_id=e.subject_id
+ LEFT JOIN readonly_fanout_model_calls f ON e.source='readonly_fanout' AND f.run_id=e.run_id
+ AND f.execution_id=json_extract(e.payload_json,'$.execution_id') AND f.shard_ordinal=json_extract(e.payload_json,'$.shard')
+ AND f.attempt_number=json_extract(e.payload_json,'$.attempt') AND f.provider=json_extract(e.payload_json,'$.provider') AND f.model=json_extract(e.payload_json,'$.model')
+ WHERE (e.source IN ('model_gateway','specialist_model_gateway') AND e.type IN ('model.started','model.completed','model.failed'))
+ OR (e.source='thread_context_compaction' AND e.type IN ('context_compaction.model_completed','context_compaction.model_failed'))
+ OR (e.source='readonly_fanout' AND e.type IN ('readonly_fanout.shard_completed','readonly_fanout.shard_failed','readonly_fanout.shard_cancelled'))
+ ORDER BY e.run_id,e.sequence`)
+ if err != nil {
+ return result, err
+ }
+ var receipts, starts []usageReceipt
+ seen := make(map[string]bool)
+ for rows.Next() {
+ var r usageReceipt
+ var at, raw string
+ var input, output, total sql.NullInt64
+ if err = rows.Scan(&r.runID, &r.sequence, &r.source, &r.kind, &r.subject, &at, &raw, &r.threadID, &r.title, &r.compactionID, &input, &output, &total); err != nil {
+ break
+ }
+ if r.at, err = parseMonetaryStoreTime(at); err != nil {
+ break
+ }
+ if err = json.Unmarshal([]byte(raw), &r.payload); err != nil {
+ break
+ }
+ if input.Valid && output.Valid && total.Valid {
+ r.legacyUsage = &llm.Usage{InputTokens: int(input.Int64), OutputTokens: int(output.Int64), TotalTokens: int(total.Int64)}
+ }
+ if r.at.After(now) || r.payload.Dispatch == "not_sent" || (r.source == "readonly_fanout" && (r.payload.ErrorCode == "request_not_dispatched" || r.payload.FanoutAttempt <= 0)) {
+ continue
+ }
+ if r.kind == "model.started" {
+ starts = append(starts, r)
+ continue
+ }
+ key := r.callKey()
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ receipts = append(receipts, r)
+ }
+ readErr := rows.Err()
+ closeErr := rows.Close()
+ if err != nil {
+ return result, err
+ }
+ if readErr != nil {
+ return result, readErr
+ }
+ if closeErr != nil {
+ return result, closeErr
+ }
+
+ // Index receipts once; settlement matching stays local to one attempt.
+ type monetaryKey struct {
+ runID, scope, provider, model string
+ number int64
+ }
+ byIdentity := make(map[monetaryKey][]usageReceipt)
+ startsByIdentity := make(map[monetaryKey][]usageReceipt)
+ for _, r := range receipts {
+ scope, number := r.monetaryIdentity()
+ if r.source == "model_gateway" && number == 0 && r.payload.Purpose == "" {
+ number = r.payload.ModelAttempt
+ }
+ if number != 0 {
+ key := monetaryKey{r.runID, scope, r.payload.Provider, r.payload.Model, number}
+ byIdentity[key] = append(byIdentity[key], r)
+ }
+ }
+ for _, r := range starts {
+ if r.source == "model_gateway" && r.payload.MonetaryKey == 0 && r.payload.Purpose == "" {
+ key := monetaryKey{r.runID, domain.MonetaryScopeRoot, r.payload.Provider, r.payload.Model, r.payload.ModelAttempt}
+ startsByIdentity[key] = append(startsByIdentity[key], r)
+ }
+ }
+
+ // Cost is independently dated by the actual reservation settlement. The
+ // settled amount can contain a conservative reservation estimate, so only
+ // a priced settlement's actual_micros is a known cost.
+ rows, err = tx.QueryContext(ctx, `SELECT r.run_id,r.scope,r.attempt_number,r.provider,r.model,r.created_at,r.settled_at,r.settled_micros,
+ COALESCE((SELECT MIN(sequence) FROM run_events WHERE run_id=r.run_id AND source='monetary_budget' AND type='monetary.budget_reserved' AND subject_id=r.id),0),
+ COALESCE(e.payload_json,'{}') FROM run_monetary_reservations r
+ LEFT JOIN run_events e ON e.run_id=r.run_id AND e.sequence=(SELECT MIN(sequence) FROM run_events WHERE run_id=r.run_id AND source='monetary_budget' AND type='monetary.budget_settled' AND subject_id=r.id)
+ WHERE r.status='settled' ORDER BY r.run_id,r.scope,r.attempt_number`)
+ if err != nil {
+ return result, err
+ }
+ settledCalls := make(map[string]bool)
+ for rows.Next() {
+ var runID, scope, provider, model, createdAt, settledAt, raw string
+ var number, amount, reservedSequence int64
+ if err = rows.Scan(&runID, &scope, &number, &provider, &model, &createdAt, &settledAt, &amount, &reservedSequence, &raw); err != nil {
+ break
+ }
+ var at time.Time
+ if at, err = parseMonetaryStoreTime(settledAt); err != nil {
+ break
+ }
+ if at.After(now) {
+ continue
+ }
+ if period == "all" && at.Before(start) {
+ start = at.In(location)
+ }
+ if period == "all" || !at.Before(start) {
+ var settlement struct {
+ Actual *int64 `json:"actual_micros"`
+ Status string `json:"pricing_status"`
+ }
+ if err = json.Unmarshal([]byte(raw), &settlement); err != nil {
+ break
+ }
+ if settlement.Actual != nil && *settlement.Actual >= 0 && *settlement.Actual < math.MaxInt64 &&
+ (settlement.Status == "" || settlement.Status == domain.MonetaryPricingPriced) && *settlement.Actual == amount {
+ result.Cost.PricedCalls++
+ result.Cost.KnownCostMicros, err = supervisorAddCounter(result.Cost.KnownCostMicros, *settlement.Actual, "usage cost")
+ if err != nil {
+ break
+ }
+ } else {
+ result.Cost.UnpricedCalls++
+ }
+ }
+ // Exact modern identities and auxiliary identities are durable. Legacy
+ // root reservations bind the first start following their reservation;
+ // terminal-only imports must have one unambiguous matching receipt.
+ key := monetaryKey{runID, scope, provider, model, number}
+ matches := byIdentity[key]
+ if scope == domain.MonetaryScopeRoot && number < 1<<61 {
+ created := parseTS(createdAt)
+ var first *usageReceipt
+ for i := range startsByIdentity[key] {
+ r := &startsByIdentity[key][i]
+ if r.sequence > reservedSequence && (reservedSequence != 0 || !r.at.Before(created)) {
+ first = r
+ break
+ }
+ }
+ var candidates []usageReceipt
+ for _, r := range matches {
+ if first != nil {
+ if r.subject == first.subject && r.sequence > first.sequence {
+ candidates = append(candidates, r)
+ }
+ } else if r.sequence > reservedSequence && (reservedSequence != 0 || !r.at.Before(created)) {
+ candidates = append(candidates, r)
+ }
+ }
+ matches = candidates
+ }
+ if len(matches) == 1 {
+ settledCalls[matches[0].callKey()] = true
+ }
+ }
+ readErr, closeErr = rows.Err(), rows.Close()
+ if err != nil {
+ return result, err
+ }
+ if readErr != nil {
+ return result, readErr
+ }
+ if closeErr != nil {
+ return result, closeErr
+ }
+
+ models := make(map[[2]string]*domain.UsageModel)
+ sessions := make(map[string]*domain.UsageSession)
+ days := make(map[string]*domain.UsageDay)
+ activity := make(map[string]*domain.UsageActivityDay)
+ for _, r := range receipts {
+ usage, e := usageReceiptTotals(r)
+ if e != nil {
+ return result, e
+ }
+ date := r.at.In(location).Format(time.DateOnly)
+ if !r.at.Before(activityStart) {
+ day := activity[date]
+ if day == nil {
+ day = &domain.UsageActivityDay{Date: date}
+ activity[date] = day
+ }
+ day.Calls++
+ day.TotalTokens, err = supervisorAddCounter(day.TotalTokens, usage.TotalTokens, "usage activity tokens")
+ if err != nil {
+ return result, err
+ }
+ }
+ if period == "all" && r.at.Before(start) {
+ start = r.at.In(location)
+ }
+ if period != "all" && r.at.Before(start) {
+ continue
+ }
+ if !settledCalls[r.callKey()] {
+ result.Cost.UntrackedCalls++
+ }
+ if err = addUsageTotals(&result.Totals, usage); err != nil {
+ return result, err
+ }
+ key := [2]string{r.payload.Provider, r.payload.Model}
+ if models[key] == nil {
+ models[key] = &domain.UsageModel{Provider: key[0], Model: key[1]}
+ }
+ if err = addUsageTotals(&models[key].Usage, usage); err != nil {
+ return result, err
+ }
+ if days[date] == nil {
+ days[date] = &domain.UsageDay{Date: date}
+ }
+ if err = addUsageTotals(&days[date].Usage, usage); err != nil {
+ return result, err
+ }
+ if r.threadID == "" {
+ result.UnassignedCalls++
+ continue
+ }
+ if sessions[r.threadID] == nil {
+ sessions[r.threadID] = &domain.UsageSession{ThreadID: r.threadID, Title: r.title}
+ }
+ session := sessions[r.threadID]
+ if r.at.After(session.LastActivityAt) {
+ session.LastActivityAt = r.at.UTC()
+ }
+ if err = addUsageTotals(&session.Usage, usage); err != nil {
+ return result, err
+ }
+ }
+ result.StartDate = start.Format(time.DateOnly)
+ for _, model := range models {
+ result.Models = append(result.Models, *model)
+ }
+ sort.Slice(result.Models, func(i, j int) bool {
+ a, b := result.Models[i], result.Models[j]
+ if a.Usage.TotalTokens != b.Usage.TotalTokens {
+ return a.Usage.TotalTokens > b.Usage.TotalTokens
+ }
+ if a.Provider != b.Provider {
+ return a.Provider < b.Provider
+ }
+ return a.Model < b.Model
+ })
+ for _, session := range sessions {
+ result.RecentSessions = append(result.RecentSessions, *session)
+ }
+ sort.Slice(result.RecentSessions, func(i, j int) bool {
+ a, b := result.RecentSessions[i], result.RecentSessions[j]
+ if !a.LastActivityAt.Equal(b.LastActivityAt) {
+ return a.LastActivityAt.After(b.LastActivityAt)
+ }
+ return a.ThreadID < b.ThreadID
+ })
+ if len(result.RecentSessions) > 8 {
+ result.RecentSessions = result.RecentSessions[:8]
+ }
+ for _, day := range days {
+ result.Days = append(result.Days, *day)
+ }
+ sort.Slice(result.Days, func(i, j int) bool { return result.Days[i].Date < result.Days[j].Date })
+ for _, day := range activity {
+ result.ActivityDays = append(result.ActivityDays, *day)
+ }
+ sort.Slice(result.ActivityDays, func(i, j int) bool { return result.ActivityDays[i].Date < result.ActivityDays[j].Date })
+ return result, nil
+}
+
+func usageReceiptTotals(r usageReceipt) (domain.UsageTotals, error) {
+ total := domain.UsageTotals{Calls: 1}
+ usage := r.payload.Usage
+ complete := !r.payload.UsageUnknown
+ if usage == nil && r.source == "readonly_fanout" {
+ usage = r.legacyUsage
+ if usage == nil && (r.payload.InputTokens != nil || r.payload.OutputTokens != nil || r.payload.TotalTokens != nil) {
+ usage = &llm.Usage{}
+ complete = complete && r.payload.InputTokens != nil && r.payload.OutputTokens != nil
+ if r.payload.InputTokens != nil {
+ usage.InputTokens = int(*r.payload.InputTokens)
+ }
+ if r.payload.OutputTokens != nil {
+ usage.OutputTokens = int(*r.payload.OutputTokens)
+ }
+ if r.payload.TotalTokens != nil {
+ usage.TotalTokens = int(*r.payload.TotalTokens)
+ }
+ }
+ }
+ if usage == nil {
+ return total, nil
+ }
+ u := *usage
+ var err error
+ total.InputTokens, total.OutputTokens, total.TotalTokens, err = supervisorUsage(u)
+ if err != nil {
+ return total, err
+ }
+ total.CacheReadTokens, total.CacheCreationTokens = int64(u.CacheReadTokens), int64(u.CacheCreationTokens)
+ if complete {
+ total.UsageReportedCalls = 1
+ }
+ if u.CacheUsageReported {
+ total.CacheUsageReportedCalls = 1
+ }
+ return total, nil
+}
+
+func addUsageTotals(target *domain.UsageTotals, value domain.UsageTotals) error {
+ for _, counter := range []struct {
+ target *int64
+ value int64
+ }{
+ {&target.InputTokens, value.InputTokens}, {&target.OutputTokens, value.OutputTokens},
+ {&target.TotalTokens, value.TotalTokens}, {&target.CacheReadTokens, value.CacheReadTokens},
+ {&target.CacheCreationTokens, value.CacheCreationTokens}, {&target.Calls, value.Calls},
+ {&target.UsageReportedCalls, value.UsageReportedCalls}, {&target.CacheUsageReportedCalls, value.CacheUsageReportedCalls},
+ } {
+ var err error
+ *counter.target, err = supervisorAddCounter(*counter.target, counter.value, "usage statistics")
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/store/usage_statistics_test.go b/internal/store/usage_statistics_test.go
new file mode 100644
index 000000000..607896a8b
--- /dev/null
+++ b/internal/store/usage_statistics_test.go
@@ -0,0 +1,260 @@
+package store
+
+import (
+ "fmt"
+ "math"
+ "strings"
+ "testing"
+ "time"
+
+ "cyberagent-workbench/internal/apperror"
+ "cyberagent-workbench/internal/domain"
+ "cyberagent-workbench/internal/events"
+ "cyberagent-workbench/internal/llm"
+)
+
+func usageTestEvent(t *testing.T, st *SQLiteStore, run domain.Run, source, kind, subject string, at time.Time, payload map[string]any) {
+ t.Helper()
+ event, err := events.New(run.ID, run.MissionID, kind, source, subject, payload)
+ if err != nil {
+ t.Fatal(err)
+ }
+ event.CreatedAt = at
+ tx, err := st.db.BeginTx(t.Context(), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer tx.Rollback()
+ if _, err := insertRunEventTx(t.Context(), tx, event); err != nil {
+ t.Fatal(err)
+ }
+ if err := tx.Commit(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestUsageStatisticsCalendarWindowsSourcesAndCoverage(t *testing.T) {
+ st, run := newMonetaryTestStore(t)
+ now := time.Date(2026, 10, 19, 17, 30, 0, 0, time.UTC)
+ boundary := time.Date(2026, 10, 19, 16, 0, 0, 0, time.UTC)
+ known := func(input, output int) llm.Usage {
+ return llm.Usage{InputTokens: input, OutputTokens: output, TotalTokens: input + output}
+ }
+ root := known(100, 20)
+ root.CacheReadTokens, root.CacheCreationTokens, root.CacheUsageReported = 40, 20, true
+ prior := known(10, 2)
+ prior.CacheReadTokens, prior.CacheCreationTokens, prior.CacheUsageReported = 3, 2, true
+ for _, r := range []struct {
+ source, kind, subject string
+ at time.Time
+ payload map[string]any
+ }{
+ {"model_gateway", events.ModelCompletedEvent, "prior", boundary.Add(-time.Second), map[string]any{"provider": "p", "model": "m", "usage": prior}},
+ {"model_gateway", events.ModelStartedEvent, "root", boundary, map[string]any{"provider": "p", "model": "m"}},
+ {"model_gateway", events.ModelCompletedEvent, "root", boundary, map[string]any{"provider": "p", "model": "m", "usage": root}},
+ {"model_gateway", events.ModelCompletedEvent, "root", boundary.Add(time.Second), map[string]any{"provider": "p", "model": "m", "usage": root}},
+ {"specialist_model_gateway", events.ModelFailedEvent, "child", boundary, map[string]any{"provider": "p", "model": "child", "usage": known(5, 1)}},
+ {"thread_context_compaction", "context_compaction.model_failed", "compact", boundary, map[string]any{"provider": "p", "model": "m", "usage": known(7, 1)}},
+ {"readonly_fanout", events.ReadOnlyFanoutShardCompletedEvent, "shard", boundary, map[string]any{"provider": "p", "model": "m", "execution_id": "fanout", "shard": 1, "attempt": 1, "usage": known(2, 1)}},
+ {"readonly_fanout", events.ReadOnlyFanoutShardFailedEvent, "shard", boundary, map[string]any{"provider": "p", "model": "m", "execution_id": "fanout", "shard": 1, "attempt": 2, "usage": known(3, 1)}},
+ {"readonly_fanout", events.ReadOnlyFanoutShardFailedEvent, "not-sent-shard", boundary, map[string]any{"attempt": 1, "error_code": "request_not_dispatched", "usage": llm.Usage{}}},
+ {"model_gateway", events.ModelFailedEvent, "not-sent", boundary, map[string]any{"dispatch": "not_sent", "usage": llm.Usage{}}},
+ {"model_gateway", events.ModelFailedEvent, "missing", boundary, map[string]any{"usage_unknown": true}},
+ {"model_gateway", events.ModelCompletedEvent, "missing-model", boundary, map[string]any{"usage": known(1, 1)}},
+ {"model_gateway", events.ModelCompletedEvent, "old", boundary.AddDate(0, 0, -30), map[string]any{"usage": known(7, 1)}},
+ {"model_gateway", events.ModelCompletedEvent, "ancient", time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), map[string]any{"usage": known(10, 1)}},
+ {"model_gateway", events.ModelCompletedEvent, "future", now.Add(time.Hour), map[string]any{"usage": known(1000, 1000)}},
+ } {
+ usageTestEvent(t, st, run, r.source, r.kind, r.subject, r.at, r.payload)
+ }
+ // Historical unbound Runs are valid local history; keep them in totals
+ // without inventing a Thread or modifying any immutable Thread binding.
+ historical := run
+ historical.ID = "unbound-usage-history"
+ _, err := st.db.Exec(`INSERT INTO runs(id,mission_id,status,config_json,budget_json,created_at,updated_at)
+ SELECT ?,mission_id,status,config_json,budget_json,created_at,updated_at FROM runs WHERE id=?`, historical.ID, run.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ usageTestEvent(t, st, historical, "model_gateway", events.ModelFailedEvent, "historical", boundary, map[string]any{"usage": known(4, 1)})
+ for _, check := range []struct {
+ period, start string
+ calls, tokens int64
+ }{
+ {"today", "2026-10-20", 8, 148},
+ {"30d", "2026-09-21", 9, 160},
+ {"all", "2024-01-01", 11, 179},
+ } {
+ t.Run(check.period, func(t *testing.T) {
+ result, err := st.GetUsageStatistics(t.Context(), check.period, 480, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Totals.Calls != check.calls || result.Totals.TotalTokens != check.tokens || result.StartDate != check.start || result.EndDate != "2026-10-20" {
+ t.Fatalf("wrong calendar totals: %+v", result)
+ }
+ if result.Totals.UsageReportedCalls != check.calls-1 || result.UnassignedCalls != 1 || len(result.RecentSessions) != 1 || result.RecentSessions[0].Usage.Calls != check.calls-1 {
+ t.Fatalf("coverage or Thread attribution changed: %+v", result)
+ }
+ if result.Cost.UntrackedCalls != check.calls || result.Cost.KnownCostMicros != 0 || result.Cost.PricedCalls != 0 || result.Cost.UnpricedCalls != 0 {
+ t.Fatalf("untracked calls became priced: %+v", result.Cost)
+ }
+ var activityTokens int64
+ for _, day := range result.ActivityDays {
+ activityTokens += day.TotalTokens
+ }
+ if activityTokens != 168 || result.ActivityEndDate != "2026-10-20" || result.ActivityStartDate != "2026-04-22" {
+ t.Fatalf("activity window follows selected period: %+v", result)
+ }
+ if result.Models[0].Provider != "p" || result.Models[0].Model != "m" || result.Models[0].Usage.TotalTokens < 100 {
+ t.Fatalf("model identities/order changed: %+v", result.Models)
+ }
+ })
+ }
+ utc, err := st.GetUsageStatistics(t.Context(), "today", 0, now)
+ if err != nil || utc.EndDate != "2026-10-19" || utc.Totals.TotalTokens != 160 {
+ t.Fatalf("fixed UTC offset ignored: %+v %v", utc, err)
+ }
+}
+
+func usageTestReservation(t *testing.T, st *SQLiteStore, run domain.Run, id, scope string, number int64, created, settled time.Time, amount int64, status string) {
+ t.Helper()
+ state := "settled"
+ var settledValue any = ts(settled)
+ if settled.IsZero() {
+ state, settledValue = "reserved", nil
+ }
+ _, err := st.db.Exec(`INSERT INTO run_monetary_reservations
+ (id,run_id,scope,provider,model,attempt_number,reserved_micros,settled_micros,released_micros,status,estimate_source,price_fingerprint,created_at,settled_at)
+ VALUES (?, ?, ?, 'p', 'm', ?, 900, ?, ?, ?, 'test', ?, ?, ?)`, id, run.ID, scope, number,
+ map[bool]int64{true: amount, false: 0}[state == "settled"], map[bool]int64{true: 900 - amount, false: 0}[state == "settled"], state,
+ strings.Repeat("a", 64), ts(created), settledValue)
+ if err != nil {
+ t.Fatal(err)
+ }
+ usageTestEvent(t, st, run, "monetary_budget", events.MonetaryBudgetReservedEvent, id, created, map[string]any{"scope": scope})
+ if state == "settled" {
+ actual := amount
+ if status != domain.MonetaryPricingPriced {
+ actual = math.MaxInt64
+ }
+ usageTestEvent(t, st, run, "monetary_budget", events.MonetaryBudgetSettledEvent, id, settled, map[string]any{"scope": scope, "actual_micros": actual, "pricing_status": status})
+ }
+}
+
+func TestUsageStatisticsCostsUseSettlementDatesAndActualPrices(t *testing.T) {
+ st, run := newMonetaryTestStore(t)
+ now := time.Date(2026, 10, 19, 17, 30, 0, 0, time.UTC)
+ boundary := time.Date(2026, 10, 19, 16, 0, 0, 0, time.UTC)
+ for i, check := range []struct {
+ key int64
+ receipt, settle time.Time
+ amount int64
+ status string
+ }{
+ {1<<61 + 1, boundary.Add(-time.Minute), boundary.Add(time.Minute), 123, domain.MonetaryPricingPriced},
+ {1<<61 + 2, boundary.Add(time.Minute), boundary.Add(2 * time.Minute), 900, domain.MonetaryPricingUsageUnknown},
+ {1<<61 + 3, boundary.Add(time.Minute), time.Time{}, 0, ""},
+ {1, boundary.Add(3 * time.Minute), boundary.Add(4 * time.Minute), 20, domain.MonetaryPricingPriced},
+ } {
+ id := fmt.Sprintf("cost-%d", i)
+ usageTestReservation(t, st, run, id, domain.MonetaryScopeRoot, check.key, boundary.Add(-time.Hour), check.settle, check.amount, check.status)
+ payload := map[string]any{"provider": "p", "model": "m", "model_attempt": check.key, "usage": llm.Usage{InputTokens: 10, OutputTokens: 2, TotalTokens: 12}}
+ if check.key >= 1<<61 {
+ payload["monetary_attempt_number"] = check.key
+ } else {
+ usageTestEvent(t, st, run, "model_gateway", events.ModelStartedEvent, id, boundary, payload)
+ }
+ usageTestEvent(t, st, run, "model_gateway", events.ModelFailedEvent, id, check.receipt, payload)
+ }
+ result, err := st.GetUsageStatistics(t.Context(), "today", 480, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Totals.Calls != 3 || result.Cost.KnownCostMicros != 143 || result.Cost.PricedCalls != 2 || result.Cost.UnpricedCalls != 1 || result.Cost.UntrackedCalls != 1 {
+ t.Fatalf("receipt dates, reserved amounts, or coverage treated as actual settlement: %+v", result)
+ }
+ all, err := st.GetUsageStatistics(t.Context(), "all", 480, now)
+ if err != nil || all.Totals.Calls != 4 || all.Cost.UntrackedCalls != 1 {
+ t.Fatalf("cross-day receipt lost its settlement: %+v %v", all, err)
+ }
+}
+
+func TestUsageStatisticsEmptyReadOnlyAndValidation(t *testing.T) {
+ st, _ := newMonetaryTestStore(t)
+ st.db.SetMaxOpenConns(1)
+ var before, after int64
+ if err := st.db.QueryRow(`SELECT total_changes()`).Scan(&before); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.db.Exec(`PRAGMA query_only=ON`); err != nil {
+ t.Fatal(err)
+ }
+ now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
+ for _, period := range []string{"", "today", "30d", "all"} {
+ result, err := st.GetUsageStatistics(t.Context(), period, -300, now)
+ if err != nil || result.Totals.Calls != 0 || result.Models == nil || result.Days == nil || result.ActivityDays == nil || result.RecentSessions == nil {
+ t.Fatalf("empty read-only usage: %+v %v", result, err)
+ }
+ }
+ if err := st.db.QueryRow(`SELECT total_changes()`).Scan(&after); err != nil || after != before {
+ t.Fatalf("usage query mutated the database: %d/%d %v", before, after, err)
+ }
+ for _, query := range []struct {
+ period string
+ offset int
+ }{{"week", 0}, {"today", 841}, {"today", -841}} {
+ _, err := st.GetUsageStatistics(t.Context(), query.period, query.offset, now)
+ if apperror.CodeOf(err) != apperror.CodeInvalidArgument {
+ t.Fatalf("invalid query accepted: %+v %v", query, err)
+ }
+ }
+}
+
+func TestUsageStatisticsLegacyFanoutAndPendingCancellation(t *testing.T) {
+ st, run := newMonetaryTestStore(t)
+ now := time.Now().UTC()
+ usageTestEvent(t, st, run, "readonly_fanout", events.ReadOnlyFanoutShardCompletedEvent, "legacy-completed", now,
+ map[string]any{"attempt": 1, "input_tokens": 4, "output_tokens": 2, "total_tokens": 6})
+ usageTestEvent(t, st, run, "readonly_fanout", events.ReadOnlyFanoutShardFailedEvent, "legacy-failed", now,
+ map[string]any{"attempt": 1, "total_tokens": 5})
+ usageTestEvent(t, st, run, "readonly_fanout", events.ReadOnlyFanoutShardCancelledEvent, "pending", now,
+ map[string]any{"attempt": 0, "provider": "", "model": "", "error_code": "operator_cancelled"})
+ result, err := st.GetUsageStatistics(t.Context(), "all", 0, now)
+ if err != nil || result.Totals.Calls != 2 || result.Totals.TotalTokens != 11 || result.Totals.InputTokens != 4 || result.Totals.OutputTokens != 2 || result.Totals.UsageReportedCalls != 1 || result.Totals.CacheUsageReportedCalls != 0 {
+ t.Fatalf("legacy tokens lost, partial usage marked complete, or pending shard counted: %+v %v", result, err)
+ }
+}
+
+func TestUsageStatisticsAuxiliarySettlementIdentities(t *testing.T) {
+ st, run := newMonetaryTestStore(t)
+ now := time.Now().UTC()
+ thread, err := st.GetThreadByRun(t.Context(), run.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = st.db.Exec(`INSERT INTO thread_context_compactions
+ (id,operation_id,thread_id,run_id,session_id,key_digest,request_fingerprint,source_sha256,status,provider,model,created_at)
+ VALUES (91,'compact-usage',?,?,?,'key','request','source','generated','p','m',?)`, thread.ID, run.ID, run.SessionID, ts(now))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i, check := range []struct {
+ source, scope, subject, kind string
+ key int64
+ extra map[string]any
+ }{
+ {"specialist_model_gateway", domain.MonetaryScopeSpecialist, "specialist", events.ModelFailedEvent, 1<<61 + 7, map[string]any{"monetary_attempt_number": int64(1<<61 + 7)}},
+ {"thread_context_compaction", domain.MonetaryScopeContextCompaction, "compact-usage", "context_compaction.model_completed", 91, map[string]any{}},
+ {"readonly_fanout", domain.MonetaryScopeReadOnlyFanout, "fanout-shard", events.ReadOnlyFanoutShardFailedEvent, int64(llm.ReadOnlyFanoutMonetaryAttemptNumber("fanout-cost", 1, 1)), map[string]any{"execution_id": "fanout-cost", "shard": 1, "attempt": 1}},
+ } {
+ usageTestReservation(t, st, run, fmt.Sprintf("aux-%d", i), check.scope, check.key, now.Add(-time.Minute), now, 10, domain.MonetaryPricingPriced)
+ payload := check.extra
+ payload["provider"], payload["model"], payload["usage"] = "p", "m", llm.Usage{InputTokens: 2, OutputTokens: 1, TotalTokens: 3}
+ usageTestEvent(t, st, run, check.source, check.kind, check.subject, now, payload)
+ }
+ result, err := st.GetUsageStatistics(t.Context(), "all", 0, now)
+ if err != nil || result.Totals.Calls != 3 || result.Cost.UntrackedCalls != 0 || result.Cost.PricedCalls != 3 || result.Cost.KnownCostMicros != 30 {
+ t.Fatalf("auxiliary receipts did not match their settled costs: %+v %v", result, err)
+ }
+}
diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts
index d549bd07d..6a18436e2 100644
--- a/web/src/api/schema.d.ts
+++ b/web/src/api/schema.d.ts
@@ -5688,6 +5688,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/v1/usage": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Inspect local application usage
+ * @description Aggregates this application's durable model terminal receipts across root agents, specialists, context compaction, and read-only fan-out; failed sent calls count and not-sent receipts are excluded. Tokens, model groups, recent Threads, sparse days, and untracked_calls use terminal receipt time. Cache tokens are input subsets; usage_reported_calls and cache_usage_reported_calls identify missing coverage. Missing provider/model identities remain empty strings. Costs use reservation settled_at independently of receipt time: known_cost_micros sums only actual priced USD settlements, unpriced_calls counts unknown-cost settlements, and untracked_calls counts receipts without a matched settlement. Reservation estimates never become known cost. Calendar dates use a fixed UTC offset; 30d includes today and the preceding 29 dates, all starts at the earliest receipt or settlement, and empty history starts today. The sparse activity_days always cover the last 182 calendar dates including today, independently of period. Collections are sorted deterministically; recent_sessions includes at most eight Threads with selected-period receipts and uses their latest receipt as last_activity_at. Unbound historical Runs contribute to totals and unassigned_calls.
+ */
+ get: operations["getUsageStatistics"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/v1/work-items/{work_item_id}": {
parameters: {
query?: never;
@@ -15418,6 +15438,77 @@ export interface components {
content_base64: string;
path: string;
};
+ UsageActivityDay: {
+ /** Format: int64 */
+ calls: number;
+ date: string;
+ /** Format: int64 */
+ total_tokens: number;
+ };
+ UsageCost: {
+ currency: string;
+ /** Format: int64 */
+ known_cost_micros: number;
+ /** Format: int64 */
+ priced_calls: number;
+ /** Format: int64 */
+ unpriced_calls: number;
+ /** Format: int64 */
+ untracked_calls: number;
+ };
+ UsageDay: {
+ date: string;
+ usage: components["schemas"]["UsageTotals"];
+ };
+ UsageModel: {
+ model: string;
+ provider: string;
+ usage: components["schemas"]["UsageTotals"];
+ };
+ UsageSession: {
+ /** Format: date-time */
+ last_activity_at: string;
+ thread_id: string;
+ title: string;
+ usage: components["schemas"]["UsageTotals"];
+ };
+ UsageStatistics: {
+ activity_days: components["schemas"]["UsageActivityDay"][];
+ activity_end_date: string;
+ activity_start_date: string;
+ cost: components["schemas"]["UsageCost"];
+ days: components["schemas"]["UsageDay"][];
+ end_date: string;
+ /** Format: date-time */
+ generated_at: string;
+ models: components["schemas"]["UsageModel"][];
+ period: string;
+ recent_sessions: components["schemas"]["UsageSession"][];
+ start_date: string;
+ totals: components["schemas"]["UsageTotals"];
+ /** Format: int64 */
+ unassigned_calls: number;
+ /** Format: int32 */
+ utc_offset_minutes: number;
+ };
+ UsageTotals: {
+ /** Format: int64 */
+ cache_creation_tokens: number;
+ /** Format: int64 */
+ cache_read_tokens: number;
+ /** Format: int64 */
+ cache_usage_reported_calls: number;
+ /** Format: int64 */
+ calls: number;
+ /** Format: int64 */
+ input_tokens: number;
+ /** Format: int64 */
+ output_tokens: number;
+ /** Format: int64 */
+ total_tokens: number;
+ /** Format: int64 */
+ usage_reported_calls: number;
+ };
VerificationAssociationControlView: {
approval: boolean;
/** Format: date-time */
@@ -30131,6 +30222,44 @@ export interface operations {
504: components["responses"]["GatewayTimeout"];
};
};
+ getUsageStatistics: {
+ parameters: {
+ query?: {
+ /** @description Calendar reporting period */
+ period?: "today" | "30d" | "all";
+ /** @description Fixed UTC offset in minutes, without daylight-saving transitions */
+ utc_offset_minutes?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful read */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ data: components["schemas"]["UsageStatistics"];
+ request_id: string;
+ /** @constant */
+ version: "api.v1";
+ };
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 414: components["responses"]["RequestTooLarge"];
+ 429: components["responses"]["ResourceExhausted"];
+ 500: components["responses"]["InternalError"];
+ 503: components["responses"]["Unavailable"];
+ 504: components["responses"]["GatewayTimeout"];
+ };
+ };
getWorkItem: {
parameters: {
query?: never;
diff --git a/web/src/v2/components/settings.tsx b/web/src/v2/components/settings.tsx
index 148d88f50..c3d7af132 100644
--- a/web/src/v2/components/settings.tsx
+++ b/web/src/v2/components/settings.tsx
@@ -31,6 +31,7 @@ import "./settings.css";
const TaskConfigurationSettings = lazy(() => import("./task-configuration-settings").then((module) => ({ default: module.TaskConfigurationSettings })));
const SandboxEnvironmentPanel = lazy(() => import("../../components/sandbox-environment-panel").then((module) => ({ default: module.SandboxEnvironmentPanel })));
const VerificationSettingsPanel = lazy(() => import("../../components/verification-settings-panel").then((module) => ({ default: module.VerificationSettingsPanel })));
+const UsageSettings = lazy(() => import("./usage-settings").then((module) => ({ default: module.UsageSettings })));
function SettingRow({ title, detail, children }: { title: string; detail: string; children: React.ReactNode }) {
return
{title} {detail}
@@ -360,7 +361,7 @@ export function V2Settings({ client, section, threadID, workspaces, onSelectSect
}) {
const { t } = useLocale();
return
-
+
{section === "general" &&
onSelectSection("permissions")}
threadID={threadID} workspaces={workspaces} />}
{section === "configuration" &&
>}
{section === "appearance" && }
+ {section === "usage" && {t("正在加载用量统计…", "Loading usage…")}}>
+
+ }
{section === "environment" && <>{t("执行环境设置", "Execution environment settings")} {t("检测用户安装,保存下次启动配置,再回到任务选择编码环境。", "Detect installed environments, save settings for the next startup, then return to your task to choose a coding environment.")}
{t("正在加载执行环境设置…", "Loading execution environment settings…")}}>
>}
@@ -401,7 +405,7 @@ export function V2Settings({ client, section, threadID, workspaces, onSelectSect
}
{section === "inspector" &&
}
{!(["general", "permissions", "appearance", "archived", "models", "inspector", "extensions", "plugins",
- "skills", "advanced-models", "about", "shortcuts", "keyboard", "connections", "task-configuration", "environment", "configuration"] as V2SettingsSection[])
+ "skills", "advanced-models", "about", "shortcuts", "keyboard", "connections", "task-configuration", "environment", "configuration", "usage"] as V2SettingsSection[])
.includes(section) &&
}
;
diff --git a/web/src/v2/components/sidebar.test.tsx b/web/src/v2/components/sidebar.test.tsx
index 0ec08eb3d..b4cb80796 100644
--- a/web/src/v2/components/sidebar.test.tsx
+++ b/web/src/v2/components/sidebar.test.tsx
@@ -139,7 +139,7 @@ describe("V2 model navigation", () => {
const navigation = screen.getByRole("navigation", { name: "设置分类" });
const labels = within(navigation).getAllByRole("button").map((button) => button.textContent);
- expect(labels).toEqual(["常规", "配置", "外观", "快捷键", "关于", "连接与环境", "模型",
+ expect(labels).toEqual(["常规", "配置", "外观", "用量统计", "快捷键", "关于", "连接与环境", "模型",
"扩展与代码智能", "Skill 包", "执行环境", "任务预算与项目配置", "当前任务权限", "观察视图偏好"]);
expect(labels).not.toContain("智能伙伴");
const models = within(navigation).getByRole("button", { name: "模型" });
diff --git a/web/src/v2/components/sidebar.tsx b/web/src/v2/components/sidebar.tsx
index f5ea039c7..6c7921319 100644
--- a/web/src/v2/components/sidebar.tsx
+++ b/web/src/v2/components/sidebar.tsx
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
-import { Archive, ArchiveRestore, ArrowLeft, Box, Cpu, Folder, MessagesSquare, RefreshCw,
+import { Archive, ArchiveRestore, ArrowLeft, ChartNoAxesCombined, Box, Cpu, Folder, MessagesSquare, RefreshCw,
Info, Keyboard, PackageSearch, Palette, PlugZap, Search, Settings, ShieldCheck, SquarePen, X } from "lucide-react";
import type { ThreadView, WorkspaceView } from "../../api/types";
import { useLocale } from "../../lib/locale";
@@ -7,7 +7,7 @@ import { useLocale } from "../../lib/locale";
export type V2SettingsSection = "general" | "permissions" | "appearance" | "voice" |
"models" | "plugins" | "browser" | "hooks" | "git" | "environment" |
"worktrees" | "keyboard" | "inspector" | "archived" | "extensions" | "skills" |
- "advanced-models" | "about" | "shortcuts" | "connections" | "task-configuration" | "configuration";
+ "advanced-models" | "about" | "shortcuts" | "connections" | "task-configuration" | "configuration" | "usage";
const executionLabels = {
idle: ["空闲", "Idle"], running: ["执行中", "Running"], stopping: ["正在停止", "Stopping"], stop_failed: ["停止失败", "Stop failed"],
@@ -156,6 +156,7 @@ const settingsGroups: Array<{ label: [string, string]; items: Array<{
{ id: "general", label: ["常规", "General"], icon: Settings },
{ id: "configuration", label: ["配置", "Configuration"], icon: Settings },
{ id: "appearance", label: ["外观", "Appearance"], icon: Palette },
+ { id: "usage", label: ["用量统计", "Usage"], icon: ChartNoAxesCombined },
{ id: "shortcuts", label: ["快捷键", "Shortcuts"], icon: Keyboard },
{ id: "about", label: ["关于", "About"], icon: Info },
] },
diff --git a/web/src/v2/components/usage-charts.tsx b/web/src/v2/components/usage-charts.tsx
new file mode 100644
index 000000000..dd593d321
--- /dev/null
+++ b/web/src/v2/components/usage-charts.tsx
@@ -0,0 +1,127 @@
+import { useId, useRef, useState } from "react";
+import { useLocale } from "../../lib/locale";
+
+export type UsageDay = { date: string; total_tokens: number; calls: number };
+
+// API dates are calendar days in the displayed UTC offset. UTC arithmetic keeps
+// these labels stable even when the browser crosses a daylight-saving boundary.
+export function usageCalendar(start: string, end: string, days: UsageDay[]): UsageDay[] {
+ const byDate = new Map(days.map((day) => [day.date, day]));
+ const result: UsageDay[] = [];
+ const date = new Date(`${start}T00:00:00Z`);
+ while (date.toISOString().slice(0, 10) <= end) {
+ const key = date.toISOString().slice(0, 10);
+ result.push(byDate.get(key) ?? { date: key, total_tokens: 0, calls: 0 });
+ date.setUTCDate(date.getUTCDate() + 1);
+ }
+ return result;
+}
+
+export function UsageTrend({ days }: { days: UsageDay[] }) {
+ const { locale, t } = useLocale();
+ const titleID = useId();
+ const compact = new Intl.NumberFormat(locale, { notation: "compact", maximumFractionDigits: 1 });
+ const byMonth = days.length > 180;
+ const points: Array<{ date: string; total: number }> = [];
+ for (const day of days) {
+ const date = byMonth ? day.date.slice(0, 7) : day.date;
+ const previous = points.at(-1);
+ if (previous?.date === date) previous.total += day.total_tokens;
+ else points.push({ date, total: day.total_tokens });
+ }
+ const peak = Math.max(0, ...points.map((point) => point.total));
+ const width = 720, height = 164, left = 4, right = 4, top = 14, bottom = 140;
+ const coordinates = points.map((point, index) => ({ ...point,
+ x: points.length === 1 ? (left + width - right) / 2 : left + index / (points.length - 1) * (width - left - right),
+ y: bottom - point.total / (peak || 1) * (bottom - top),
+ }));
+ const line = coordinates.map((point) => `${point.x},${point.y}`).join(" ");
+ const dateLabel = (date: string) => byMonth ? date : date.slice(5).replace("-", "/");
+ return
+ {t("用量趋势", "Usage over time")}
+ {byMonth ? t("按月", "Monthly") : t("按日", "Daily")}{" / "}{t("峰值", "Peak")} {compact.format(peak)}
+
+ {(peak > 0 ? [1, 0.5, 0] : [0]).map((fraction) => {compact.format(peak * fraction)} )}
+
+ {(peak > 0 ? [0, 0.5, 1] : [0]).map((fraction) => )}
+ {coordinates.length > 1 && <>
+ >}
+ {coordinates.length === 1 && }
+
+ {points.length > 0 && {dateLabel(points[0].date)}
+ {points.length > 2 && {dateLabel(points[Math.floor(points.length / 2)].date)} }
+ {points.length > 1 && {dateLabel(points[points.length - 1].date)} }
}
+ {byMonth ? t("每月明细", "Monthly details") : t("每日明细", "Daily details")}
+
+ {t("日期", "Date")} Token
+ {points.map((point) => {point.date} {point.total.toLocaleString(locale)} )}
+
+
+ ;
+}
+
+export function UsageActivity({ days }: { days: UsageDay[] }) {
+ const { locale, t } = useLocale();
+ const titleID = useId();
+ const [selectedDate, setSelectedDate] = useState("");
+ const tooltipID = useId();
+ const [tooltipDate, setTooltipDate] = useState("");
+ const cells = useRef
>([]);
+ const selected = Math.max(0, selectedDate ? days.findIndex((day) => day.date === selectedDate) : days.length - 1);
+ const current = days[selected];
+ const activeDays = days.filter((day) => day.calls > 0).length;
+ const peak = Math.max(1, ...days.map((day) => day.total_tokens));
+ const leading = days.length ? (new Date(`${days[0].date}T00:00:00Z`).getUTCDay() + 6) % 7 : 0;
+ const columns = Math.ceil((leading + days.length) / 7);
+ const tooltipIndex = days.findIndex((day) => day.date === tooltipDate);
+ const tooltipDay = days[tooltipIndex];
+ const tooltipCell = leading + tooltipIndex;
+ const showDate = (date: string) => { setSelectedDate(date); setTooltipDate(date); };
+ const months: Array<{ column: number; label: string }> = [];
+ days.forEach((day, index) => {
+ const column = Math.floor((leading + index) / 7);
+ if ((index === 0 && Number(day.date.slice(-2)) <= 7 || day.date.endsWith("-01")) && column < columns - 1) {
+ months.push({ column, label: new Date(`${day.date}T00:00:00Z`).toLocaleDateString(locale, { month: "short", timeZone: "UTC" }) });
+ }
+ });
+ return
+ {t("活动", "Activity")}
+ {t(`近 26 周,活跃 ${activeDays} 天`, `Last 26 weeks, ${activeDays} active days`)}
+ {months.map((month) =>
+ {month.label} )}
+
+ {Array.from({ length: leading }, (_, index) =>
)}
+ {days.map((day, index) => {
+ const level = day.total_tokens > 0 ? Math.max(1, Math.ceil(day.total_tokens / peak * 4)) : day.calls > 0 ? 1 : 0;
+ const label = t(`${day.date}:${day.total_tokens.toLocaleString(locale)} Token,${day.calls} 次调用`,
+ `${day.date}: ${day.total_tokens.toLocaleString(locale)} tokens, ${day.calls} calls`);
+ return
{ cells.current[index] = node; }}
+ onFocus={() => showDate(day.date)} onMouseEnter={() => showDate(day.date)} onClick={() => showDate(day.date)}
+ onBlur={() => setTooltipDate("")} onMouseLeave={(event) => {
+ if (document.activeElement !== event.currentTarget) setTooltipDate("");
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Escape") { event.preventDefault(); setTooltipDate(""); return; }
+ const offsets: Record = { ArrowLeft: -7, ArrowRight: 7, ArrowUp: -1, ArrowDown: 1, Home: -index, End: days.length - 1 - index };
+ if (!(event.key in offsets)) return;
+ event.preventDefault();
+ const next = Math.max(0, Math.min(days.length - 1, index + offsets[event.key]));
+ cells.current[next]?.focus();
+ }} />;
+ })}
+ {tooltipDay && }
+
+ {current ? `${current.date} / ${current.total_tokens.toLocaleString(locale)} Token` : ""}
+ {t("少", "Less")}
+ {[0, 1, 2, 3, 4].map((level) => )}{t("多", "More")}
+ ;
+}
diff --git a/web/src/v2/components/usage-settings.css b/web/src/v2/components/usage-settings.css
new file mode 100644
index 000000000..129d485d4
--- /dev/null
+++ b/web/src/v2/components/usage-settings.css
@@ -0,0 +1,107 @@
+.v2-settings-content.v2-usage-content { max-width: 960px; padding-top: 38px; }
+.v2-usage { --usage-chart: #92bce8; font-variant-numeric: tabular-nums; }
+[data-prayu-theme="light"] .v2-usage { --usage-chart: #326ba4; }
+.v2-usage-heading { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 20px; margin-bottom: 34px; }
+.v2-usage-heading h1 { font-size: 24px; font-weight: 600; line-height: 1.4; margin: 0 0 6px; }
+.v2-usage-heading p { color: var(--v2-muted); font-size: 13px; line-height: 1.6; margin: 0; }
+.v2-usage-controls { display: flex; align-items: center; gap: 10px; }
+.v2-usage-controls .v2-setting-segmented { flex-shrink: 0; }
+.v2-usage-controls .v2-setting-segmented button { min-height: 34px; padding-inline: 12px; white-space: nowrap; }
+.v2-usage-refresh { align-items: center; display: flex; justify-content: center; background: none; color: var(--v2-muted); border: 1px solid transparent; border-radius: 8px; padding: 8px; cursor: pointer; }
+.v2-usage-refresh:hover { background: var(--v2-control-hover); color: var(--v2-ink); }
+.v2-usage-refresh:disabled { opacity: .45; cursor: wait; }
+.v2-usage-overview { display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 24px 40px; }
+.v2-usage-total, .v2-usage-cost { display: grid; gap: 10px; min-width: 0; }
+.v2-usage-total > span, .v2-usage-cost > span { color: var(--v2-muted); font-size: 13px; }
+.v2-usage-total > strong { font-family: var(--prayu-font-mono); font-size: clamp(28px, 4.6vw, 52px); font-weight: 600; line-height: 1.18; letter-spacing: -.055em; overflow-wrap: anywhere; }
+.v2-usage-total p { display: grid; gap: 4px; }
+.v2-usage-total p, .v2-usage-cost p { color: var(--v2-muted); font-size: 12px; line-height: 1.6; margin: 0; }
+.v2-usage-total p > span { font-size: 11px; }
+.v2-usage-cost { gap: 8px; padding-top: 1px; }
+.v2-usage-cost > strong { font-family: var(--prayu-font-mono); font-size: 25px; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
+.v2-usage-breakdown { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin: 28px 0 10px; padding-top: 22px; border-top: 1px solid var(--v2-border); }
+.v2-usage-breakdown > div { min-width: 0; }
+.v2-usage-breakdown dt { color: var(--v2-muted); font-size: 12px; margin-bottom: 7px; }
+.v2-usage-breakdown dd { font-size: clamp(14px, 1.8vw, 19px); font-weight: 500; line-height: 1.4; margin: 0; overflow-wrap: anywhere; }
+.v2-usage-coverage, .v2-usage-empty { color: var(--v2-muted); font-size: 12px; line-height: 1.7; margin: 0; }
+.v2-usage-empty { padding: 12px 0; }
+.v2-usage-empty-period { border-inline-start: 2px solid var(--usage-chart); color: var(--v2-muted); font-size: 13px; line-height: 1.7; padding-inline-start: 12px; margin: 22px 0 0; }
+.v2-usage-section-heading { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 18px; }
+.v2-usage-section-heading h2 { font-size: 15px; font-weight: 600; line-height: 1.5; margin: 0; }
+.v2-usage-section-heading > span { color: var(--v2-muted); font-size: 11px; }
+.v2-usage-trend { margin-top: 32px; }
+.v2-usage-plot { display: grid; grid-template-columns: 42px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); gap: 10px; height: 164px; }
+.v2-usage-y-axis { position: relative; color: var(--v2-muted); font-size: 11px; }
+.v2-usage-y-axis > span { position: absolute; right: 0; transform: translateY(-50%); }
+.v2-usage-trend svg { display: block; width: 100%; height: 100%; min-height: 0; overflow: visible; }
+.v2-usage-gridline { stroke: var(--v2-border); stroke-width: .6; stroke-dasharray: 3 5; }
+.v2-usage-area { fill: var(--usage-chart); opacity: .08; }
+.v2-usage-line { fill: none; stroke: var(--usage-chart); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }
+.v2-usage-point { fill: var(--usage-chart); }
+.v2-usage-axis-labels { display: flex; justify-content: space-between; margin: -4px 4px 0 56px; color: var(--v2-muted); font-size: 11px; }
+.v2-usage-daily-details { color: var(--v2-muted); font-size: 11px; margin-top: 12px; }
+.v2-usage summary { cursor: pointer; width: fit-content; line-height: 1.7; }
+.v2-usage-daily-details > div { max-height: 220px; overflow: auto; margin-top: 8px; }
+.v2-usage-daily-details table { width: 100%; border-collapse: collapse; font-size: 12px; }
+.v2-usage-daily-details th, .v2-usage-daily-details td { text-align: start; padding: 6px 10px; font-weight: 400; border-bottom: 1px solid var(--v2-border); }
+.v2-usage-daily-details td, .v2-usage-daily-details thead th:last-child { text-align: end; }
+.v2-usage-lists { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 36px; margin: 28px 0 30px; padding-top: 24px; border-top: 1px solid var(--v2-border); }
+.v2-usage-lists > section { min-width: 0; }
+.v2-usage-models, .v2-usage-sessions { list-style: none; margin: 0; padding: 0; max-height: 360px; overflow: auto; scrollbar-width: thin; }
+.v2-usage-models li + li { margin-top: 16px; }
+.v2-usage-model-heading { display: grid; grid-template-columns: minmax(0, 1fr) auto 48px; align-items: baseline; gap: 8px; font-size: 13px; }
+.v2-usage-model-heading > strong { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.v2-usage-model-heading > span:last-child { text-align: end; color: var(--v2-muted); font-size: 11px; }
+.v2-usage-model-track { height: 3px; border-radius: 2px; background: color-mix(in srgb, var(--usage-chart) 10%, transparent); margin: 7px 0 5px; overflow: hidden; }
+.v2-usage-model-track > i { display: block; background: var(--usage-chart); height: 100%; border-radius: inherit; opacity: .85; }
+.v2-usage-models p, .v2-usage-sessions p { color: var(--v2-muted); display: flex; justify-content: space-between; gap: 12px; font-size: 11px; line-height: 1.5; margin: 0; }
+.v2-usage-models p > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.v2-usage-models p > span:last-child, .v2-usage-sessions p > span:last-child { flex-shrink: 0; }
+.v2-usage-sessions li + li { margin-top: 15px; }
+.v2-usage-sessions li > div { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; font-size: 13px; margin-bottom: 5px; }
+.v2-usage-sessions li > div > span { flex-shrink: 0; }
+.v2-usage-sessions button { background: none; color: var(--v2-ink); border: 0; padding: 0; display: inline-flex; align-items: center; gap: 4px; min-width: 0; text-align: start; cursor: pointer; }
+.v2-usage-sessions button:hover { color: var(--usage-chart); }
+.v2-usage-sessions button > span, .v2-usage-sessions li > div > strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 400; }
+.v2-usage-sessions button > svg { flex-shrink: 0; color: var(--v2-muted); }
+.v2-usage-activity { border-top: 1px solid var(--v2-border); padding-top: 24px; }
+.v2-usage-heatmap-months { position: relative; color: var(--v2-muted); height: 24px; font-size: 11px; }
+.v2-usage-heatmap-months > span { position: absolute; }
+.v2-usage-heatmap { --usage-heatmap-gap: 4px; position: relative; display: grid; grid-template-rows: repeat(7, auto); grid-auto-flow: column; gap: var(--usage-heatmap-gap); }
+.v2-usage-heatmap button { display: block; width: 100%; aspect-ratio: 1; border-radius: 3px; border: 0; padding: 0; cursor: pointer; }
+.v2-usage .v2-usage-heatmap button[data-active="true"] { outline: 2px solid #fff; outline-offset: -1px; box-shadow: 0 0 0 1px rgb(0 0 0 / 30%); }
+.v2-usage-tooltip { position: absolute; z-index: 2; width: 140px; box-sizing: border-box; padding: 9px 12px; transform: translate(-50%, calc(-100% - 8px)); pointer-events: none; border: 1px solid rgb(255 255 255 / 12%); border-radius: 8px; background: rgb(27 34 44 / 96%); color: #c6d1df; box-shadow: 0 4px 12px rgb(0 0 0 / 20%); text-align: center; font-size: 12px; line-height: 1.5; }
+.v2-usage-tooltip strong { color: #f4f7fa; font-size: 14px; font-weight: 600; }
+.v2-usage-tooltip time { display: block; font-size: 11px; margin-top: 2px; }
+.v2-usage [data-level="0"] { background: color-mix(in srgb, var(--v2-muted) 10%, transparent); }
+.v2-usage [data-level="1"] { background: color-mix(in srgb, var(--usage-chart) 26%, transparent); }
+.v2-usage [data-level="2"] { background: color-mix(in srgb, var(--usage-chart) 48%, transparent); }
+.v2-usage [data-level="3"] { background: color-mix(in srgb, var(--usage-chart) 72%, transparent); }
+.v2-usage [data-level="4"] { background: var(--usage-chart); }
+.v2-usage-activity-caption { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; color: var(--v2-muted); font-size: 11px; line-height: 1.7; margin-top: 12px; }
+.v2-usage-legend { display: flex; align-items: center; gap: 4px; }
+.v2-usage-legend i { display: block; width: 10px; height: 10px; border-radius: 2px; }
+.v2-usage-footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; margin-top: 28px; border-top: 1px solid var(--v2-border); padding-top: 16px; color: var(--v2-muted); font-size: 11px; line-height: 1.7; }
+.v2-usage-method { color: var(--v2-muted); font-size: 12px; margin-top: 12px; }
+.v2-usage-method p { max-width: 76ch; line-height: 1.8; margin: 10px 0; }
+.v2-usage-message { margin: 36px 0; color: var(--v2-muted); font-size: 14px; line-height: 1.7; }
+.v2-usage button:focus-visible, .v2-usage summary:focus-visible, .v2-usage-daily-details > div:focus-visible { outline: 2px solid var(--prayu-focus); outline-offset: 3px; }
+@media (max-width: 1050px) {
+ .v2-usage-lists { grid-template-columns: minmax(0, 1fr); gap: 28px; }
+ .v2-usage-lists > section + section { border-top: 1px solid var(--v2-border); padding-top: 24px; }
+}
+@media (max-width: 600px) {
+ .v2-settings-content.v2-usage-content { padding-top: 24px; }
+ .v2-usage-heading { margin-bottom: 26px; }
+ .v2-usage-controls .v2-setting-segmented button { padding-inline: 8px; }
+ .v2-usage-breakdown { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
+ .v2-usage-breakdown dd { font-size: 17px; }
+ .v2-usage-plot { height: 140px; }
+ .v2-usage-heatmap { --usage-heatmap-gap: 2px; }
+ .v2-usage-heatmap button { border-radius: 2px; }
+}
+@media (forced-colors: active) {
+ .v2-usage { --usage-chart: Highlight; }
+ .v2-usage-heatmap button { border: 1px solid CanvasText; }
+ .v2-usage-heatmap button[data-level]:not([data-level="0"]) { forced-color-adjust: none; background: Highlight; }
+}
diff --git a/web/src/v2/components/usage-settings.test.tsx b/web/src/v2/components/usage-settings.test.tsx
new file mode 100644
index 000000000..9df66a5bd
--- /dev/null
+++ b/web/src/v2/components/usage-settings.test.tsx
@@ -0,0 +1,161 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { APIClient } from "../../api/client";
+import type { components } from "../../api/schema";
+import { LocaleProvider, useLocale } from "../../lib/locale";
+import { UsageSettings } from "./usage-settings";
+import { UsageActivity, UsageTrend, usageCalendar } from "./usage-charts";
+import { V2Settings } from "./settings";
+
+type UsageStatistics = components["schemas"]["UsageStatistics"];
+const totals = { calls: 4, usage_reported_calls: 3, cache_usage_reported_calls: 2,
+ input_tokens: 1200, output_tokens: 300, total_tokens: 1500, cache_read_tokens: 800, cache_creation_tokens: 100 };
+const emptyTotals = { calls: 0, usage_reported_calls: 0, cache_usage_reported_calls: 0,
+ input_tokens: 0, output_tokens: 0, total_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 };
+const data: UsageStatistics = {
+ generated_at: "2026-10-19T04:30:00Z", period: "30d", utc_offset_minutes: 480,
+ start_date: "2026-09-20", end_date: "2026-10-19", totals,
+ cost: { currency: "USD", known_cost_micros: 125000, priced_calls: 2, unpriced_calls: 1, untracked_calls: 1 },
+ unassigned_calls: 1, models: [{ provider: "test-provider", model: "test-model", usage: totals }],
+ recent_sessions: [{ thread_id: "thread-a", title: "修复设置布局", last_activity_at: "2026-10-19T04:00:00Z", usage: totals }],
+ days: [{ date: "2026-10-19", usage: totals }],
+ activity_start_date: "2026-04-21", activity_end_date: "2026-10-19",
+ activity_days: [{ date: "2026-10-19", calls: 4, total_tokens: 1500 }],
+};
+
+beforeEach(() => localStorage.setItem("prayu.locale.v1", "en-US"));
+afterEach(() => { localStorage.removeItem("prayu.locale.v1"); vi.restoreAllMocks(); });
+
+function setup(get = vi.fn().mockResolvedValue(data), integrated = false) {
+ const onOpenThread = vi.fn();
+ const client = { get } as unknown as APIClient;
+ const queries = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ function LocaleSwitch() {
+ const { setLocale } = useLocale();
+ return setLocale("zh-CN")}>Switch language ;
+ }
+ render( {integrated
+ ?
+ : }
+ );
+ return { get, onOpenThread };
+}
+
+it("mounts Usage in Settings, counts cache inside input, and opens the selected conversation", async () => {
+ const { onOpenThread, get } = setup(undefined, true);
+ const overview = await screen.findByRole("region", { name: "Usage overview" });
+ expect(within(overview).getByText("1,500")).toBeInTheDocument();
+ expect(within(overview).getByText("$0.13")).toHaveAttribute("title", "USD 0.125000");
+ expect(screen.getByText(/2 \/ 4 calls reported cache details/)).toHaveTextContent("Calls without token usage: 1.");
+ expect(screen.getByText("1 calls have no conversation and are included in totals.")).toBeInTheDocument();
+ expect(get).toHaveBeenCalledWith("/usage", { period: "30d", utc_offset_minutes: -new Date().getTimezoneOffset() }, expect.any(AbortSignal));
+ await userEvent.click(screen.getByRole("button", { name: "Open conversation: 修复设置布局" }));
+ expect(onOpenThread).toHaveBeenCalledExactlyOnceWith("thread-a");
+});
+
+it("fetches the selected period and translates the entire dashboard without changing user titles", async () => {
+ const { get } = setup();
+ await screen.findByRole("region", { name: "Usage overview" });
+ await userEvent.click(screen.getByRole("button", { name: "Today" }));
+ await waitFor(() => expect(get).toHaveBeenLastCalledWith("/usage", expect.objectContaining({ period: "today" }), expect.any(AbortSignal)));
+ await userEvent.click(screen.getByRole("button", { name: "All time" }));
+ await waitFor(() => expect(get).toHaveBeenLastCalledWith("/usage", expect.objectContaining({ period: "all" }), expect.any(AbortSignal)));
+ await userEvent.click(screen.getByRole("button", { name: "Switch language" }));
+ expect(screen.getByRole("heading", { name: "用量统计" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "全部" })).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByRole("button", { name: "打开对话:修复设置布局" })).toBeInTheDocument();
+ expect(screen.getByText("已记录费用估算")).toBeInTheDocument();
+ expect(screen.queryByText("Recorded cost estimate")).not.toBeInTheDocument();
+});
+
+it("distinguishes missing usage/pricing from a reported zero cost", async () => {
+ setup(vi.fn().mockResolvedValue({ ...data, totals: { ...totals, cache_usage_reported_calls: 0 },
+ cost: { ...data.cost, priced_calls: 0, known_cost_micros: 0 } }));
+ const overview = await screen.findByRole("region", { name: "Usage overview" });
+ expect(within(overview).getByText("—")).toBeInTheDocument();
+ expect(within(overview).queryByText("$0.00")).not.toBeInTheDocument();
+ expect(screen.getByText("Cache read").parentElement).toHaveTextContent("—");
+ expect(screen.getByText("Cache write").parentElement).toHaveTextContent("—");
+ expect(screen.getByText("No priced settlements yet")).toBeInTheDocument();
+});
+
+it("shows a useful empty state while preserving an explicitly recorded zero cost", async () => {
+ setup(vi.fn().mockResolvedValue({ ...data, totals: emptyTotals, models: [], recent_sessions: [], days: [], activity_days: [],
+ cost: { ...data.cost, priced_calls: 1, known_cost_micros: 0, unpriced_calls: 0, untracked_calls: 0 } }));
+ await screen.findByRole("region", { name: "Usage overview" });
+ expect(screen.getByRole("status")).toHaveTextContent("No model calls in this period.");
+ expect(screen.getByText("$0.00")).toBeInTheDocument();
+ expect(screen.getByText("No model usage yet")).toBeInTheDocument();
+ expect(screen.getByText("No conversation usage yet")).toBeInTheDocument();
+});
+
+it("offers retry when loading fails and restores the real data", async () => {
+ const get = vi.fn().mockRejectedValueOnce(new Error("offline")).mockResolvedValue(data);
+ setup(get);
+ expect(await screen.findByRole("alert")).toHaveTextContent("Usage could not be loaded.");
+ expect(screen.queryByRole("region", { name: "Usage overview" })).not.toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Retry" }));
+ expect(await screen.findByRole("region", { name: "Usage overview" })).toHaveTextContent("1,500");
+});
+
+it("fills calendar gaps through DST and exposes heatmap dates with one keyboard tab stop", async () => {
+ const days = usageCalendar("2026-10-31", "2026-11-02", [{ date: "2026-11-02", calls: 2, total_tokens: 80 }]);
+ expect(days).toEqual([{ date: "2026-10-31", calls: 0, total_tokens: 0 },
+ { date: "2026-11-01", calls: 0, total_tokens: 0 }, { date: "2026-11-02", calls: 2, total_tokens: 80 }]);
+ render( );
+ const heatmap = screen.getByRole("group", { name: /Daily activity/ });
+ const cells = within(heatmap).getAllByRole("button");
+ expect(cells.filter((cell) => cell.tabIndex === 0)).toHaveLength(1);
+ await userEvent.tab();
+ expect(cells[2]).toHaveFocus();
+ await userEvent.keyboard("{ArrowUp}");
+ expect(cells[1]).toHaveFocus();
+ expect(cells[1]).toHaveAccessibleName("2026-11-01: 0 tokens, 0 calls");
+});
+
+it("groups long histories by month without losing sparse daily usage", async () => {
+ const days = usageCalendar("2026-04-01", "2026-10-01", [
+ { date: "2026-04-01", calls: 1, total_tokens: 10 },
+ { date: "2026-04-02", calls: 1, total_tokens: 20 },
+ { date: "2026-10-01", calls: 1, total_tokens: 5 },
+ ]);
+ render( );
+ expect(screen.getByText("Monthly / Peak 30")).toBeInTheDocument();
+ await userEvent.click(screen.getByText("Monthly details"));
+ expect(screen.getByRole("row", { name: "2026-04 30" })).toBeVisible();
+ expect(screen.getByRole("row", { name: "2026-10 5" })).toBeVisible();
+});
+
+it("highlights a heatmap date and shows its tokens on hover, click, and keyboard focus", async () => {
+ const user = userEvent.setup();
+ render(Outside heatmap );
+ const cells = within(screen.getByRole("group", { name: /Daily activity/ })).getAllByRole("button");
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
+ await user.hover(cells[0]);
+ expect(cells[0]).toHaveAttribute("data-active", "true");
+ expect(cells[0]).toHaveAttribute("aria-describedby", screen.getByRole("tooltip").id);
+ expect(screen.getByRole("tooltip")).toHaveTextContent("1.4B tokens");
+ expect(within(screen.getByRole("tooltip")).getByText("2026-08-28")).toHaveAttribute("dateTime", "2026-08-28");
+ await user.unhover(cells[0]);
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
+ expect(cells[0]).not.toHaveAttribute("data-active");
+ await user.click(cells[0]);
+ await user.unhover(cells[0]);
+ expect(screen.getByRole("tooltip")).toHaveTextContent("1.4B tokens");
+ await user.keyboard("{ArrowDown}");
+ expect(cells[1]).toHaveFocus();
+ expect(cells[1]).toHaveAttribute("data-active", "true");
+ expect(screen.getByRole("tooltip")).toHaveTextContent("80 tokens2026-08-29");
+ await user.keyboard("{Escape}");
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
+ expect(cells[1]).toHaveFocus();
+ await user.click(cells[0]);
+ await user.tab();
+ expect(screen.getByRole("button", { name: "Outside heatmap" })).toHaveFocus();
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
+});
diff --git a/web/src/v2/components/usage-settings.tsx b/web/src/v2/components/usage-settings.tsx
new file mode 100644
index 000000000..fc97686d0
--- /dev/null
+++ b/web/src/v2/components/usage-settings.tsx
@@ -0,0 +1,123 @@
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { ArrowUpRight, RefreshCw } from "lucide-react";
+import type { APIClient } from "../../api/client";
+import type { components } from "../../api/schema";
+import { useLocale } from "../../lib/locale";
+import { v2QueryKeys } from "../query-keys";
+import { UsageActivity, UsageTrend, usageCalendar } from "./usage-charts";
+import "./usage-settings.css";
+
+type Period = "today" | "30d" | "all";
+type UsageStatistics = components["schemas"]["UsageStatistics"];
+
+export function UsageSettings({ client, onOpenThread }: { client: APIClient; onOpenThread?: (id: string) => void }) {
+ const { locale, t } = useLocale();
+ const [period, setPeriod] = useState("30d");
+ const utcOffsetMinutes = -new Date().getTimezoneOffset();
+ const query = useQuery({
+ queryKey: v2QueryKeys.usage(period, utcOffsetMinutes),
+ queryFn: ({ signal }) => client.get("/usage", { period, utc_offset_minutes: utcOffsetMinutes }, signal),
+ staleTime: 30_000,
+ refetchInterval: 60_000,
+ });
+ const data = query.data;
+ const number = (value: number) => value.toLocaleString(locale);
+ const compact = new Intl.NumberFormat(locale, { notation: "compact", maximumFractionDigits: 1 });
+ const timezone = `UTC${utcOffsetMinutes >= 0 ? "+" : "−"}${String(Math.floor(Math.abs(utcOffsetMinutes) / 60)).padStart(2, "0")}:${String(Math.abs(utcOffsetMinutes) % 60).padStart(2, "0")}`;
+ const daily = data ? usageCalendar(data.start_date, data.end_date,
+ data.days.map((day) => ({ date: day.date, ...day.usage }))) : [];
+ const activity = data ? usageCalendar(data.activity_start_date, data.activity_end_date, data.activity_days) : [];
+ const cost = data?.cost;
+ const costAmount = (cost?.known_cost_micros ?? 0) / 1_000_000;
+ const knownCost = cost && cost.priced_calls > 0 ? new Intl.NumberFormat(locale, {
+ style: "currency", currency: "USD", minimumFractionDigits: 2, maximumFractionDigits: costAmount >= 0.01 ? 2 : 6,
+ }).format(costAmount) : "—";
+
+ return
+
{t("用量统计", "Usage")}
+
{t("Universal-Code 的模型调用与本地费用", "Model usage and local costs in Universal-Code")}
+
+ {([ ["today", t("今日", "Today")], ["30d", t("近 30 天", "30 days")], ["all", t("全部", "All time")] ] as const).map(([value, label]) =>
+ setPeriod(value)}>{label} )}
+
void query.refetch()}>
+
+ {query.isPending &&
{t("正在读取模型用量…", "Loading model usage…")}
}
+ {query.isError &&
{data
+ ? t("刷新失败,当前显示上次读取的用量。", "Refresh failed. Showing the last loaded usage.")
+ : t("无法读取用量统计。请检查连接后重试。", "Usage could not be loaded. Check the connection and try again.")}
+
void query.refetch()}>{t("重试", "Retry")} }
+ {data && <>
+
+ {t("已记录 Token", "Recorded tokens")}
+
{number(data.totals.total_tokens)}
+
{t(`${number(data.totals.calls)} 次模型调用`, `${number(data.totals.calls)} model calls`)}{data.start_date} — {data.end_date}
+
+ {t("已记录费用估算", "Recorded cost estimate")} 0 ? `USD ${costAmount.toFixed(6)}` : undefined}>{knownCost}
+
{cost && cost.priced_calls > 0
+ ? t(`${number(cost.priced_calls)} 次已定价结算 / USD`, `${number(cost.priced_calls)} priced settlements / USD`)
+ : t("尚无已定价的结算记录", "No priced settlements yet")}
+ {cost && cost.unpriced_calls > 0 &&
{t(`${number(cost.unpriced_calls)} 次结算缺少完整计价`, `Settlements with incomplete pricing: ${number(cost.unpriced_calls)}`)}
}
+ {cost && cost.untracked_calls > 0 &&
{t(`${number(cost.untracked_calls)} 次调用未记录费用`, `Calls without a recorded cost: ${number(cost.untracked_calls)}`)}
}
+
+
+
+
{t("输入", "Input")} {number(data.totals.input_tokens)}
+
{t("输出", "Output")} {number(data.totals.output_tokens)}
+
{t("缓存读取", "Cache read")} {data.totals.cache_usage_reported_calls > 0 ? number(data.totals.cache_read_tokens) : "—"}
+
{t("缓存写入", "Cache write")} {data.totals.cache_usage_reported_calls > 0 ? number(data.totals.cache_creation_tokens) : "—"}
+
+
{t("缓存已包含在输入中。", "Cache tokens are included in input.")}{" "}
+ {t(`${number(data.totals.cache_usage_reported_calls)} / ${number(data.totals.calls)} 次调用返回缓存明细。`,
+ `${number(data.totals.cache_usage_reported_calls)} / ${number(data.totals.calls)} calls reported cache details.`)}
+ {data.totals.usage_reported_calls < data.totals.calls && <> {t(
+ `另有 ${number(data.totals.calls - data.totals.usage_reported_calls)} 次调用未返回 Token 用量。`,
+ `Calls without token usage: ${number(data.totals.calls - data.totals.usage_reported_calls)}.`)}>}
+
+ {data.totals.calls === 0 &&
{t(
+ "这段时间还没有模型调用。开始一次对话后,用量会显示在这里。", "No model calls in this period. Start a conversation to see usage here.")}
}
+
+
+
{t("模型", "Models")}
+ {t("Token 占比", "Token share")}
+ {!data.models.length && {t("暂无模型用量记录", "No model usage yet")}
}
+ {data.models.map((model) => {
+ const name = model.model || t("未记录模型", "Unrecorded model");
+ const share = data.totals.total_tokens > 0 ? model.usage.total_tokens / data.totals.total_tokens * 100 : 0;
+ return
+ {name}
+ {compact.format(model.usage.total_tokens)} {share.toFixed(1)}%
+
+ {model.provider || t("未记录供应商", "Unrecorded provider")} {t(`${number(model.usage.calls)} 次调用`, `${number(model.usage.calls)} calls`)}
+ ;
+ })}
+
+
{t("最近会话", "Recent conversations")}
+ {t("期间用量", "Period usage")}
+ {!data.recent_sessions.length && {t("暂无对话用量记录", "No conversation usage yet")}
}
+ {data.recent_sessions.map((session) =>
+ {onOpenThread ?
onOpenThread(session.thread_id)}>{session.title}
+ :
{session.title} }
+
{compact.format(session.usage.total_tokens)}
+ {new Date(session.last_activity_at).toLocaleString(locale, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}
+ {t(`${number(session.usage.calls)} 次调用`, `${number(session.usage.calls)} calls`)}
+ )}
+ {data.unassigned_calls > 0 && {t(
+ `${number(data.unassigned_calls)} 次历史调用未关联对话,已计入总量。`,
+ `${number(data.unassigned_calls)} calls have no conversation and are included in totals.`)}
}
+
+
+
+
{timezone} / {t("按此偏移统计日期", "Dates use this UTC offset")}
+ {t("更新于", "Updated")} {new Date(data.generated_at).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })}
+
{t("统计说明", "How usage is counted")} {t(
+ "包含 Universal-Code 主代理、子代理、并行调用和上下文压缩的已完成调用。失败但已经产生用量的调用也会统计;Token 以供应商返回的用量为准。",
+ "Includes completed calls from Universal-Code’s main agent, subagents, parallel calls, and context compaction. Failed calls with usage are included. Token counts come from provider receipts.")}
{t(
+ "Token 按调用完成时间统计;费用按结算时间统计,仅累加已有完整价格记录的本地估算。尚未计价或尚未结算的调用不包含在金额内,实际费用以供应商账单为准。",
+ "Tokens are grouped by call completion time. Costs use settlement time and include only local estimates with complete pricing. Unpriced and unsettled calls are excluded from the amount; provider billing is authoritative.")}
+ >}
+
;
+}
diff --git a/web/src/v2/navigation.test.ts b/web/src/v2/navigation.test.ts
index 79222fa29..f21c9999a 100644
--- a/web/src/v2/navigation.test.ts
+++ b/web/src/v2/navigation.test.ts
@@ -15,6 +15,7 @@ it("restores task/settings identity and rejects malformed links without guessing
expect(readV2Route("#/new")).toEqual({ kind: "new" });
expect(readV2Route("#/new/settings/task-configuration")).toEqual({ kind: "new", section: "task-configuration" });
expect(readV2Route("#/new/settings/configuration")).toEqual({ kind: "new", section: "configuration" });
+ expect(readV2Route("#/new/settings/usage")).toEqual({ kind: "new", section: "usage" });
expect(readV2Route("#/threads/thread-42/settings/configuration")).toEqual({ kind: "thread", threadID: "thread-42", section: "configuration" });
for (const hash of ["#/threads/%ZZ", "#/threads/../../another", "#/threads/%2fother", "#/threads/task/settings/unknown"]) {
expect(readV2Route(hash)).toEqual({ kind: "invalid" });
diff --git a/web/src/v2/navigation.ts b/web/src/v2/navigation.ts
index 9ecebf9f8..bbf40f4a1 100644
--- a/web/src/v2/navigation.ts
+++ b/web/src/v2/navigation.ts
@@ -7,7 +7,7 @@ export type V2Route = { kind: "initial" | "new" | "thread" | "invalid";
threadID?: string; section?: V2SettingsSection; view?: "inspector";
tool?: "run" | "session" | "schedule"; resourceID?: string; pane?: V2RunPane };
const sections: string[] = ["general", "models", "permissions", "appearance", "inspector", "archived",
- "extensions", "skills", "advanced-models", "about", "shortcuts", "connections", "task-configuration", "configuration"];
+ "extensions", "skills", "advanced-models", "about", "shortcuts", "connections", "task-configuration", "configuration", "usage"];
// Fragments work with both the loopback UI and the existing Desktop asset
// server. History contains navigation identity only, never tokens or drafts.
diff --git a/web/src/v2/query-keys.ts b/web/src/v2/query-keys.ts
index f1a357397..1fa898c9b 100644
--- a/web/src/v2/query-keys.ts
+++ b/web/src/v2/query-keys.ts
@@ -10,4 +10,5 @@ export const v2QueryKeys = {
approvals: (runID: string) => ["v2", "run", runID, "approvals"] as const,
workspaces: ["v2", "workspaces"] as const,
inspectorRecords: ["v2", "inspector", "records"] as const,
+ usage: (period: string, utcOffsetMinutes: number) => ["v2", "usage", period, utcOffsetMinutes] as const,
};