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 ? [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")} +
+ + {points.map((point) => )} +
{t("日期", "Date")}Token
{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`)}
+ +
+ {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
+
{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 ; + } + 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(); + 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]) => + )} +
+
+ {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.")}

+
} + {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 ? + : {session.title}} + {compact.format(session.usage.total_tokens)}
    +

    + {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, };