Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add llm usage cost and cache stats

Lyric 15d171c5 784e7c6b

+1980 -366
+3 -1
agent/context.go
··· 46 46 if c.Metrics.TotalTokens == 0 { 47 47 c.Metrics.TotalTokens = usage.InputTokens + usage.OutputTokens 48 48 } 49 - c.Metrics.TotalCost += usage.Cost 49 + if usage.Cost != nil { 50 + c.Metrics.TotalCost += usage.Cost.Total 51 + } 50 52 c.Metrics.ElapsedMs = time.Since(c.Metrics.StartTime).Milliseconds() 51 53 _ = dur 52 54 }
+14 -4
agent/context_test.go
··· 39 39 func TestAddUsageAccumulatesCost(t *testing.T) { 40 40 ctx := NewContext("test", 5) 41 41 42 - usage1 := llm.Usage{InputTokens: 100, OutputTokens: 50, TotalTokens: 150, Cost: 0.05} 42 + usage1 := llm.Usage{ 43 + InputTokens: 100, 44 + OutputTokens: 50, 45 + TotalTokens: 150, 46 + Cost: &llm.UsageCost{Currency: "USD", Total: 0.05}, 47 + } 43 48 ctx.AddUsage(usage1, time.Second) 44 49 if !almostEqual(ctx.Metrics.TotalCost, 0.05) { 45 50 t.Errorf("expected TotalCost≈0.05, got %f", ctx.Metrics.TotalCost) 46 51 } 47 52 48 - usage2 := llm.Usage{InputTokens: 200, OutputTokens: 100, TotalTokens: 300, Cost: 0.10} 53 + usage2 := llm.Usage{ 54 + InputTokens: 200, 55 + OutputTokens: 100, 56 + TotalTokens: 300, 57 + Cost: &llm.UsageCost{Currency: "USD", Total: 0.10}, 58 + } 49 59 ctx.AddUsage(usage2, time.Second) 50 60 if !almostEqual(ctx.Metrics.TotalCost, 0.15) { 51 61 t.Errorf("expected TotalCost≈0.15, got %f", ctx.Metrics.TotalCost) 52 62 } 53 63 } 54 64 55 - func TestAddUsageZeroCostNoChange(t *testing.T) { 65 + func TestAddUsageNilCostNoChange(t *testing.T) { 56 66 ctx := NewContext("test", 5) 57 67 58 - usage := llm.Usage{InputTokens: 100, OutputTokens: 50, TotalTokens: 150, Cost: 0} 68 + usage := llm.Usage{InputTokens: 100, OutputTokens: 50, TotalTokens: 150} 59 69 ctx.AddUsage(usage, time.Second) 60 70 if ctx.Metrics.TotalCost != 0 { 61 71 t.Errorf("expected TotalCost=0, got %f", ctx.Metrics.TotalCost)
+3
assets/config/config.example.yaml
··· 35 35 reasoning_effort: "" 36 36 # Optional default reasoning budget tokens. If empty/unset, do not call uniai.WithReasoningBudgetTokens(...). 37 37 # reasoning_budget_tokens: 8192 38 + # Optional local pricing catalog YAML to override uniai's embedded default prices. 39 + # Relative paths resolve from the config.yaml directory. 40 + # pricing_file: "./pricing.yaml" 38 41 # Tool-call emulation mode for providers/models without native tool calling. 39 42 # Values: off | fallback | force 40 43 tools_emulation_mode: "off"
+6
cmd/mistermorph/install_config_wizard_test.go
··· 190 190 if gotAPIKey := cfg.GetString("llm.api_key"); gotAPIKey != "sk-openai-compatible" { 191 191 t.Fatalf("llm.api_key = %q, want sk-openai-compatible", gotAPIKey) 192 192 } 193 + if gotPricingFile := cfg.GetString("llm.pricing_file"); gotPricingFile != "" { 194 + t.Fatalf("llm.pricing_file = %q, want empty", gotPricingFile) 195 + } 193 196 if strings.Contains(got, "\n cloudflare:\n") || strings.Contains(got, "account_id:") || strings.Contains(got, "api_token:") { 194 197 t.Fatalf("patched config should not include cloudflare block: %s", got) 195 198 } ··· 217 220 } 218 221 if gotProvider := cfg.GetString("llm.provider"); gotProvider != "openai" { 219 222 t.Fatalf("llm.provider = %q, want openai", gotProvider) 223 + } 224 + if gotPricingFile := cfg.GetString("llm.pricing_file"); gotPricingFile != "" { 225 + t.Fatalf("llm.pricing_file = %q, want empty", gotPricingFile) 220 226 } 221 227 var endpoints []map[string]any 222 228 if err := cfg.UnmarshalKey("console.endpoints", &endpoints); err != nil {
+1
cmd/mistermorph/root.go
··· 216 216 _, _ = fmt.Fprintf(os.Stderr, "Failed to read config: %v\n", err) 217 217 return 218 218 } 219 + viper.Set("config", cfgFile) 219 220 expandConfiguredDirKey("file_state_dir") 220 221 expandConfiguredDirKey("file_cache_dir") 221 222 }
+303
docs/feat/feat_20260418_uniai_v016_usage_cost.md
··· 1 + --- 2 + date: 2026-04-18 3 + title: uniai v0.1.16 升级与 Usage Cost 接入 4 + status: draft 5 + --- 6 + 7 + # uniai v0.1.16 升级与 Usage Cost 接入 8 + 9 + ## 1) 目标 10 + 11 + 这次只解决一个很具体的问题: 12 + 13 + 1. 将 `github.com/quailyquaily/uniai` 依赖声明更新到 `v0.1.16` 14 + 2. 把 `uniai` 的价格配置注入能力接到 `mistermorph` 15 + 3. 让 `mistermorph` 在最终 `usage` 里直接读到 cache token 与 cost 信息 16 + 17 + 不在这次范围里的事: 18 + 19 + - 不做账单对账 20 + - 不做 Console 新价格视图 21 + - 不改 LLM usage journal 的存储格式 22 + - 不引入第二套 pricing schema 23 + 24 + ## 2) 直接回答问题 25 + 26 + 问题是: 27 + 28 + > `uniai v0.1.16` 支持注入价格配置,这样可以在 usage 里直接读到 cost 相关字段,对吧? 29 + 30 + 结论分两层。 31 + 32 + ### 2.1 在 `uniai` 里,答案是对 33 + 34 + 当且仅当下面两个条件同时成立时,`uniai` 会填充 `Usage.Cost`: 35 + 36 + 1. `Config.Pricing != nil` 37 + 2. pricing catalog 里存在匹配当前 `provider + model` 的规则 38 + 39 + 此时: 40 + 41 + - `Usage.Cache` 会带缓存 token 拆分 42 + - 阻塞 `Chat()` 返回值会带 `resp.Usage.Cost` 43 + - 流式场景下,最终一次 `ev.Usage` 也会带 `Cost` 44 + 45 + 但这个 `Cost` 是本地推导值,不是上游厂商账单原文。 46 + 47 + ### 2.2 在当前 `mistermorph` 里,答案还不是 48 + 49 + 当前代码还没有把这条链路接通: 50 + 51 + - `go.mod` 里还是 `uniai v0.1.11` 52 + - `providers/uniai/client.go` 只复制了顶层 token 数,没有复制 cache 和 cost 53 + - `llm.Usage` 目前只有一个扁平的 `Cost float64` 54 + - 配置层还没有把 pricing catalog 传给 `uniai.Config` 55 + 56 + 所以现在即使底层 `uniai` 能算出 `Usage.Cost`,`mistermorph` 这一层也会把它丢掉。 57 + 58 + ## 3) 当前事实 59 + 60 + 基于当前仓库代码,已经确认这些事实: 61 + 62 + - `go.mod` 当前声明的是 `github.com/quailyquaily/uniai v0.1.11` 63 + - 开发工作区里的 `go.work` 已经把 sibling `uniai` 模块纳入进来 64 + - 当前本地 `uniai` 工作区版本已经是 `v0.1.16` 65 + - `uniai v0.1.16` 已有: 66 + - `Config.Pricing *PricingCatalog` 67 + - `ParsePricingYAML([]byte)` 68 + - `resp.Usage.Cache` 69 + - `resp.Usage.Cost` 70 + - 最终流式 `ev.Usage.Cost` 71 + - `providers/uniai/client.go` 当前在两个位置只映射了: 72 + - `InputTokens` 73 + - `OutputTokens` 74 + - `TotalTokens` 75 + - 当前没有映射: 76 + - `Cache.CachedInputTokens` 77 + - `Cache.CacheCreationInputTokens` 78 + - `Cache.Details` 79 + - `Cost` 80 + - `agent/context.go` 目前按 `usage.Cost` 累加 `Metrics.TotalCost` 81 + - `internal/llmstats` 目前只记录 token,不记录价格 82 + 83 + 这里还有一个很容易误判的点: 84 + 85 + > 本地开发时,因为 `go.work` 的存在,测试和编译可能已经实际使用 `v0.1.16` 源码;但发布和非 workspace 环境仍然以 `go.mod` 为准。 86 + 87 + 所以这次需要同时处理“依赖声明”和“运行时映射”,不能只改其中一边。 88 + 89 + ## 4) 第一性原理约束 90 + 91 + 1. cost 的计算源头只能有一个。 92 + 应该复用 `uniai` 的 `PricingCatalog` 计算结果,不在 `mistermorph` 再算一次。 93 + 94 + 2. 不要重新定义 pricing schema。 95 + 直接复用 `uniai.ParsePricingYAML(...)` 和它已有的 YAML 格式。 96 + 97 + 3. 共享 `llm.Usage` 应该贴近上游真实语义。 98 + 如果上游 `Usage.Cost` 是结构化对象,这里就直接接结构化对象,不再额外发明一个扁平总价字段。 99 + 100 + 4. 聚合逻辑应该依赖结构化 cost 的 `Total`。 101 + `Metrics.TotalCost` 只是一个聚合结果,不应该反过来主导共享数据结构设计。 102 + 103 + 5. cache token 与 cache cost 是同一个问题的一部分。 104 + 如果已经接 `Usage.Cost`,就不能把 `Usage.Cache` 丢掉,否则 cache cost 的来源不完整。 105 + 106 + 6. 老数据不迁移。 107 + 缺失的新字段一律按 0 或 `nil` 处理,不单独做回填脚本。 108 + 109 + ## 5) 建议方案 110 + 111 + ### 5.1 依赖升级 112 + 113 + 把 `go.mod` 中的 `github.com/quailyquaily/uniai` 更新到 `v0.1.16`。 114 + 115 + 这是发布正确性的要求,不是样式问题。 116 + 117 + ### 5.2 共享 `llm.Usage` 结构最小扩展 118 + 119 + 建议直接让 `llm.Usage` 对齐 `uniai` 的 cost 语义: 120 + 121 + ```go 122 + type Usage struct { 123 + InputTokens int 124 + OutputTokens int 125 + TotalTokens int 126 + Cache UsageCache 127 + Cost *UsageCost 128 + } 129 + 130 + type UsageCache struct { 131 + CachedInputTokens int 132 + CacheCreationInputTokens int 133 + Details map[string]int 134 + } 135 + 136 + type UsageCost struct { 137 + Currency string 138 + Estimated bool 139 + Input float64 140 + CachedInput float64 141 + CacheCreationInput float64 142 + Output float64 143 + Total float64 144 + } 145 + ``` 146 + 147 + 为什么这比“保留 `float64 Cost` 再补一个 `CostBreakdown`”更简单: 148 + 149 + - 不重复保存同一个事实 150 + - provider 映射只需要维护一种 cost 形状 151 + - 调用方不会遇到 `Cost` 和 `CostBreakdown.Total` 不一致的问题 152 + - `mistermorph` 不再维护一套自定义 cost 语义 153 + - cache token 与 cache cost 能在同一份 `Usage` 里闭合 154 + 155 + 这会带来一个明确代价: 156 + 157 + - 现有直接访问 `usage.Cost` 这个 `float64` 的代码要一起改 158 + 159 + 但当前仓库里这类使用点很少,主要就是 `agent.Context` 的总价累加和少量测试。 160 + 为了换取更干净的数据模型,这个代价是可接受的。 161 + 162 + ### 5.3 provider 映射 163 + 164 + `providers/uniai/client.go` 需要在两条路径上都补全映射: 165 + 166 + 1. `Client.Chat(...)` 的最终 `resp.Usage` 167 + 2. `WithOnStream(...)` 的最终 `ev.Usage` 168 + 169 + 映射规则: 170 + 171 + - `llm.Usage.Cache = mapped(resp.Usage.Cache)` 172 + - `llm.Usage.Cost = mapped(resp.Usage.Cost)` 173 + - 若上游 `Cache` 缺字段,则对应字段保留零值 174 + - 若上游 `Cost == nil`,则 `Cost = nil` 175 + 176 + 这样最终 `usage` 上的 cache 和 cost 语义就和 `uniai` 一致,不需要额外转换出第二个总价字段。 177 + 178 + ### 5.4 pricing 配置注入 179 + 180 + 建议只加一个最小入口: 181 + 182 + ```yaml 183 + llm: 184 + pricing_file: "./pricing.yaml" 185 + ``` 186 + 187 + 实现规则: 188 + 189 + 1. 读取 `llm.pricing_file` 190 + 2. 文件非空时读取 YAML 内容 191 + 3. 调用 `uniai.ParsePricingYAML(...)` 192 + 4. 将结果传入 `uniaiProvider.Config.Pricing` 193 + 194 + 一旦 pricing 命中,`uniai` 会基于: 195 + 196 + - `Usage.InputTokens` 197 + - `Usage.OutputTokens` 198 + - `Usage.Cache.CachedInputTokens` 199 + - `Usage.Cache.CacheCreationInputTokens` 200 + - `Usage.Cache.Details` 201 + 202 + 推导出: 203 + 204 + - `Cost.Input` 205 + - `Cost.CachedInput` 206 + - `Cost.CacheCreationInput` 207 + - `Cost.Output` 208 + - `Cost.Total` 209 + 210 + 这次不建议做: 211 + 212 + - `llm.pricing` 内联大对象 213 + - profile 级单独 pricing file 214 + - 自定义 mistermorph pricing schema 215 + 216 + 原因很简单: 217 + 218 + - 一个 pricing catalog 已经能覆盖多个 provider/model 219 + - 直接复用 `uniai` 现有 YAML,认知成本最低 220 + - profile 级拆分并不是当前问题的必要条件 221 + 222 + ### 5.5 指标与统计边界 223 + 224 + `agent.Context.Metrics.TotalCost` 改为累加 `usage.Cost.Total`。 225 + 这意味着: 226 + 227 + - 主 agent 运行时总价可以直接继续工作 228 + - 不需要在这次把 `agent.Context` 改成复杂结构 229 + - 聚合逻辑和共享 `Usage` 模型的职责边界更清楚 230 + 231 + 但 `internal/llmstats` 这次先不扩。 232 + 233 + 原因: 234 + 235 + - 现有 usage journal 设计文档明确以 token 为主 236 + - 把价格落盘会引入新的存储兼容问题 237 + - 这与“先把 usage 读到 cost”不是一个最小问题 238 + 239 + ## 6) 预期行为 240 + 241 + 完成后,行为应该是这样的: 242 + 243 + 1. 未配置 `llm.pricing_file` 244 + - `usage.Cache` 仍可有值 245 + - `usage.Cost == nil` 246 + 247 + 2. 配置了 `llm.pricing_file`,但没有匹配规则 248 + - `usage.Cache` 仍可有值 249 + - `usage.Cost == nil` 250 + 251 + 3. 配置了 `llm.pricing_file`,且规则命中 252 + - `usage.Cache` 带缓存 token 拆分 253 + - `usage.Cost != nil` 254 + - `usage.Cost.Total > 0` 255 + - 若存在缓存命中或缓存写入: 256 + - `usage.Cost.CachedInput >= 0` 257 + - `usage.Cost.CacheCreationInput >= 0` 258 + 259 + 4. 流式场景 260 + - 只有最终那次 `ev.Usage` 才应该带 cost 261 + - 中间 delta 不应伪造不完整的价格 262 + 263 + 5. 老数据或旧响应 264 + - 没有 `Cache` 字段时,缓存 token 视为 0 265 + - 没有 `Cost` 字段时,cost 视为未知,即 `nil` 266 + - 读取方如果只关心数值聚合,缺失项按 0 处理 267 + 268 + ## 7) 测试点 269 + 270 + 至少补这些测试: 271 + 272 + 1. `providers/uniai/client_test.go` 273 + - 阻塞结果映射 `Usage.Cache` 274 + - 阻塞结果映射 `Usage.Cost` 275 + - 流式最终事件映射 `Usage.Cache` 276 + - 流式最终事件映射 `Usage.Cost` 277 + 278 + 2. `llm/llm_test.go` 279 + - `Usage.Cache` 缺字段时零值正确 280 + - `Usage.Cost` 为 `nil` 时默认行为正确 281 + - `Usage.Cost.Total` 可正常读取 282 + 283 + 3. `agent/context_test.go` 284 + - `usage.Cost != nil` 时,`TotalCost` 按 `usage.Cost.Total` 累加 285 + - `usage.Cost == nil` 时,`TotalCost` 保持不变 286 + 287 + 4. `internal/llmutil` 相关测试 288 + - `llm.pricing_file` 未设置时不报错 289 + - pricing file 非法 YAML 时返回清晰错误 290 + - pricing file 合法时能成功注入到 provider config 291 + 292 + ## 8) 实施顺序 293 + 294 + 建议按下面顺序做: 295 + 296 + 1. 更新 `go.mod` 到 `uniai v0.1.16` 297 + 2. 扩展 `llm.Usage` 结构 298 + 3. 补 `providers/uniai` 的 cost 映射 299 + 4. 补 `providers/uniai` 的 cache 映射 300 + 5. 接 `llm.pricing_file` 到 `uniai.Config.Pricing` 301 + 6. 补测试 302 + 303 + 这个顺序能保证每一步都小,而且每一步都能独立验证。
+2
internal/configbootstrap/bootstrap.go
··· 13 13 Endpoint string 14 14 Model string 15 15 APIKey string 16 + PricingFile string 16 17 CloudflareAccountID string 17 18 CloudflareAPIToken string 18 19 } ··· 110 111 SetOrDeleteMappingScalar(llmNode, "provider", provider) 111 112 SetOrDeleteMappingScalar(llmNode, "endpoint", strings.TrimSpace(cfg.Endpoint)) 112 113 SetOrDeleteMappingScalar(llmNode, "model", strings.TrimSpace(cfg.Model)) 114 + SetOrDeleteMappingScalar(llmNode, "pricing_file", strings.TrimSpace(cfg.PricingFile)) 113 115 114 116 if strings.EqualFold(provider, "cloudflare") { 115 117 SetOrDeleteMappingScalar(llmNode, "api_key", "")
+26 -9
internal/daemonruntime/server_llmstats_test.go
··· 23 23 journal := llmstats.NewJournal(statepaths.LLMUsageJournalDir(), llmstats.JournalOptions{}) 24 24 defer func() { _ = journal.Close() }() 25 25 if _, err := journal.Append(llmstats.RequestRecord{ 26 - TS: time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC).Format(time.RFC3339), 27 - Provider: "openai", 28 - APIBase: "https://api.openai.com", 29 - Model: "gpt-5.2", 30 - InputTokens: 8, 31 - OutputTokens: 4, 32 - TotalTokens: 12, 26 + TS: time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC).Format(time.RFC3339), 27 + Provider: "openai", 28 + APIBase: "https://api.openai.com", 29 + Model: "gpt-5.2", 30 + InputTokens: 8, 31 + OutputTokens: 4, 32 + TotalTokens: 12, 33 + CachedInputTokens: 2, 34 + CacheCreationInputTokens: 1, 35 + CostCurrency: "USD", 36 + CostEstimated: true, 37 + CachedInputCost: 0.002, 38 + CacheCreationInputCost: 0.001, 39 + TotalCost: 0.015, 33 40 }); err != nil { 34 41 t.Fatalf("Append() error = %v", err) 35 42 } ··· 47 54 48 55 var payload struct { 49 56 Summary struct { 50 - Requests int64 `json:"requests"` 51 - TotalTokens int64 `json:"total_tokens"` 57 + Requests int64 `json:"requests"` 58 + TotalTokens int64 `json:"total_tokens"` 59 + CachedInputTokens int64 `json:"cached_input_tokens"` 60 + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` 61 + CostCurrency string `json:"cost_currency"` 62 + TotalCost float64 `json:"total_cost"` 52 63 } `json:"summary"` 53 64 APIHosts []struct { 54 65 APIHost string `json:"api_host"` ··· 59 70 } 60 71 if payload.Summary.Requests != 1 || payload.Summary.TotalTokens != 12 { 61 72 t.Fatalf("summary = %+v", payload.Summary) 73 + } 74 + if payload.Summary.CachedInputTokens != 2 || payload.Summary.CacheCreationInputTokens != 1 { 75 + t.Fatalf("summary cache = %+v", payload.Summary) 76 + } 77 + if payload.Summary.CostCurrency != "USD" || payload.Summary.TotalCost < 0.014999 || payload.Summary.TotalCost > 0.015001 { 78 + t.Fatalf("summary cost = %+v", payload.Summary) 62 79 } 63 80 if len(payload.APIHosts) != 1 || payload.APIHosts[0].APIHost != "api.openai.com" { 64 81 t.Fatalf("api_hosts = %+v", payload.APIHosts)
+34 -11
internal/llmstats/client.go
··· 77 77 } 78 78 79 79 rec := normalizeRequestRecord(RequestRecord{ 80 - TS: c.now().UTC().Format(time.RFC3339), 81 - RunID: RunIDFromContext(ctx), 82 - OriginEventID: OriginEventIDFromContext(ctx), 83 - Provider: c.Provider, 84 - APIBase: c.APIBase, 85 - Model: firstNonEmpty(strings.TrimSpace(req.Model), c.DefaultModel), 86 - Scene: strings.TrimSpace(req.Scene), 87 - InputTokens: int64(res.Usage.InputTokens), 88 - OutputTokens: int64(res.Usage.OutputTokens), 89 - TotalTokens: int64(res.Usage.TotalTokens), 90 - DurationMs: durationMillis(res.Duration, c.now().Sub(start)), 80 + TS: c.now().UTC().Format(time.RFC3339), 81 + RunID: RunIDFromContext(ctx), 82 + OriginEventID: OriginEventIDFromContext(ctx), 83 + Provider: c.Provider, 84 + APIBase: c.APIBase, 85 + Model: firstNonEmpty(strings.TrimSpace(req.Model), c.DefaultModel), 86 + Scene: strings.TrimSpace(req.Scene), 87 + InputTokens: int64(res.Usage.InputTokens), 88 + OutputTokens: int64(res.Usage.OutputTokens), 89 + TotalTokens: int64(res.Usage.TotalTokens), 90 + CachedInputTokens: int64(res.Usage.Cache.CachedInputTokens), 91 + CacheCreationInputTokens: int64(res.Usage.Cache.CacheCreationInputTokens), 92 + CacheDetails: toInt64Map(res.Usage.Cache.Details), 93 + DurationMs: durationMillis(res.Duration, c.now().Sub(start)), 91 94 }) 95 + if res.Usage.Cost != nil { 96 + rec.CostCurrency = strings.TrimSpace(res.Usage.Cost.Currency) 97 + rec.CostEstimated = res.Usage.Cost.Estimated 98 + rec.InputCost = res.Usage.Cost.Input 99 + rec.CachedInputCost = res.Usage.Cost.CachedInput 100 + rec.CacheCreationInputCost = res.Usage.Cost.CacheCreationInput 101 + rec.OutputCost = res.Usage.Cost.Output 102 + rec.TotalCost = res.Usage.Cost.Total 103 + } 92 104 if _, recErr := c.Journal.Append(rec); recErr != nil && c.Logger != nil { 93 105 c.Logger.Warn( 94 106 "llm_usage_record_error", ··· 99 111 ) 100 112 } 101 113 return res, nil 114 + } 115 + 116 + func toInt64Map(in map[string]int) map[string]int64 { 117 + if len(in) == 0 { 118 + return nil 119 + } 120 + out := make(map[string]int64, len(in)) 121 + for key, value := range in { 122 + out[key] = int64(value) 123 + } 124 + return out 102 125 } 103 126 104 127 func (c *UsageClient) Close() error {
+32
internal/llmstats/client_test.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "math" 6 7 "os" 7 8 "path/filepath" 8 9 "testing" ··· 11 12 "github.com/quailyquaily/mistermorph/llm" 12 13 ) 13 14 15 + const testCostEpsilon = 1e-9 16 + 17 + func costAlmostEqual(a, b float64) bool { 18 + return math.Abs(a-b) < testCostEpsilon 19 + } 20 + 14 21 type stubUsageClient struct{} 15 22 16 23 func (stubUsageClient) Chat(ctx context.Context, req llm.Request) (llm.Result, error) { ··· 20 27 InputTokens: 11, 21 28 OutputTokens: 7, 22 29 TotalTokens: 18, 30 + Cache: llm.UsageCache{ 31 + CachedInputTokens: 5, 32 + CacheCreationInputTokens: 3, 33 + Details: map[string]int{ 34 + "ephemeral_5m_input_tokens": 3, 35 + }, 36 + }, 37 + Cost: &llm.UsageCost{ 38 + Currency: "USD", 39 + Estimated: true, 40 + Input: 0.01, 41 + CachedInput: 0.002, 42 + CacheCreationInput: 0.003, 43 + Output: 0.02, 44 + Total: 0.035, 45 + }, 23 46 }, 24 47 Duration: 250 * time.Millisecond, 25 48 }, nil ··· 66 89 } 67 90 if rec.Scene != "agent.step" || rec.APIHost != "api.openai.com" || rec.TotalTokens != 18 { 68 91 t.Fatalf("record content = %+v", rec) 92 + } 93 + if rec.CachedInputTokens != 5 || rec.CacheCreationInputTokens != 3 { 94 + t.Fatalf("record cache tokens = %+v", rec) 95 + } 96 + if got := rec.CacheDetails["ephemeral_5m_input_tokens"]; got != 3 { 97 + t.Fatalf("record cache details = %+v", rec.CacheDetails) 98 + } 99 + if rec.CostCurrency != "USD" || !rec.CostEstimated || !costAlmostEqual(rec.TotalCost, 0.035) { 100 + t.Fatalf("record cost = %+v", rec) 69 101 } 70 102 }
+135 -4
internal/llmstats/projection.go
··· 6 6 "encoding/json" 7 7 "fmt" 8 8 "io" 9 + "log/slog" 9 10 "os" 10 11 "path/filepath" 11 12 "strings" 12 13 "time" 13 14 14 15 "github.com/quailyquaily/mistermorph/internal/fsstore" 16 + "github.com/quailyquaily/mistermorph/internal/pricingutil" 17 + uniaiapi "github.com/quailyquaily/uniai" 18 + "github.com/spf13/viper" 15 19 ) 16 20 21 + const projectionSchemaVersion = 2 22 + 17 23 type ProjectionStore struct { 18 - journalDir string 19 - path string 20 - now func() time.Time 24 + journalDir string 25 + path string 26 + now func() time.Time 27 + loadPricing func() (*uniaiapi.PricingCatalog, string, error) 28 + logger *slog.Logger 21 29 } 22 30 23 31 type aggregateState struct { ··· 37 45 journalDir: strings.TrimSpace(journalDir), 38 46 path: strings.TrimSpace(path), 39 47 now: time.Now, 48 + loadPricing: func() (*uniaiapi.PricingCatalog, string, error) { 49 + return pricingutil.LoadCatalog(viper.GetString("llm.pricing_file"), viper.GetString("config")) 50 + }, 51 + logger: slog.Default(), 40 52 } 41 53 } 42 54 43 55 func (s *ProjectionStore) Refresh() (Projection, error) { 56 + startedAt := time.Now() 57 + logger := s.logger 58 + if logger == nil { 59 + logger = slog.Default() 60 + } 61 + pricing, pricingDigest, err := s.currentPricing() 62 + if err != nil { 63 + return Projection{}, err 64 + } 44 65 proj, ok, err := loadProjection(s.path) 45 66 if err != nil || !ok { 46 67 proj = Projection{} ··· 59 80 } 60 81 61 82 start := proj.ProjectedOffset 62 - if !offsetValidForSegments(s.journalDir, segments, start) { 83 + rebuildReasons := projectionRebuildReasons(proj, ok, pricingDigest, offsetValidForSegments(s.journalDir, segments, start)) 84 + rebuild := len(rebuildReasons) > 0 85 + if rebuild { 86 + logger.Info("llm_usage_projection_rebuild", 87 + "reasons", strings.Join(rebuildReasons, ","), 88 + "schema_version", proj.SchemaVersion, 89 + "expected_schema_version", projectionSchemaVersion, 90 + "pricing_digest", strings.TrimSpace(proj.PricingDigest), 91 + "expected_pricing_digest", strings.TrimSpace(pricingDigest), 92 + "projected_records", proj.ProjectedRecords, 93 + "from_file", start.File, 94 + "from_line", start.Line, 95 + ) 63 96 proj = Projection{} 64 97 start = Offset{} 65 98 } 66 99 67 100 state := aggregateStateFromProjection(proj) 68 101 nextOffset, skipped, err := scanJournalFrom(s.journalDir, segments, start, func(rec RequestRecord, _ Offset) error { 102 + rec = backfillRequestCost(rec, pricing) 69 103 state.add(rec) 70 104 return nil 71 105 }) ··· 75 109 state.skipped += skipped 76 110 77 111 out := state.toProjection() 112 + out.SchemaVersion = projectionSchemaVersion 113 + out.PricingDigest = pricingDigest 78 114 out.UpdatedAt = s.now().UTC().Format(time.RFC3339) 79 115 out.ProjectedOffset = nextOffset 80 116 out.ProjectedRecords = out.Summary.Requests 81 117 if err := saveProjection(s.path, out); err != nil { 82 118 return Projection{}, err 83 119 } 120 + mode := "incremental" 121 + if rebuild { 122 + mode = "rebuild" 123 + } 124 + logger.Info("llm_usage_projection_refreshed", 125 + "mode", mode, 126 + "duration_ms", time.Since(startedAt).Milliseconds(), 127 + "journal_segments", len(segments), 128 + "projected_records", out.ProjectedRecords, 129 + "skipped_records", out.SkippedRecords, 130 + "to_file", out.ProjectedOffset.File, 131 + "to_line", out.ProjectedOffset.Line, 132 + ) 84 133 return out, nil 134 + } 135 + 136 + func (s *ProjectionStore) currentPricing() (*uniaiapi.PricingCatalog, string, error) { 137 + if s == nil || s.loadPricing == nil { 138 + return nil, "", nil 139 + } 140 + return s.loadPricing() 85 141 } 86 142 87 143 func aggregateStateFromProjection(p Projection) *aggregateState { ··· 163 219 sortAPIHostSummaries(hosts) 164 220 165 221 return Projection{ 222 + SchemaVersion: projectionSchemaVersion, 166 223 Summary: s.summary, 167 224 APIHosts: hosts, 168 225 Models: models, ··· 190 247 return nil 191 248 } 192 249 return fsstore.WriteJSONAtomic(path, proj, fsstore.FileOptions{}) 250 + } 251 + 252 + func projectionCompatible(proj Projection, pricingDigest string) bool { 253 + if proj.SchemaVersion != projectionSchemaVersion { 254 + return false 255 + } 256 + return strings.TrimSpace(proj.PricingDigest) == strings.TrimSpace(pricingDigest) 257 + } 258 + 259 + func projectionRebuildReasons(proj Projection, projectionExists bool, pricingDigest string, offsetValid bool) []string { 260 + reasons := make([]string, 0, 3) 261 + if !projectionExists { 262 + reasons = append(reasons, "missing_projection") 263 + } 264 + if !offsetValid { 265 + reasons = append(reasons, "offset_invalid") 266 + } 267 + if proj.SchemaVersion != projectionSchemaVersion { 268 + reasons = append(reasons, "schema_version_changed") 269 + } 270 + if strings.TrimSpace(proj.PricingDigest) != strings.TrimSpace(pricingDigest) { 271 + reasons = append(reasons, "pricing_digest_changed") 272 + } 273 + return reasons 274 + } 275 + 276 + func backfillRequestCost(rec RequestRecord, pricing *uniaiapi.PricingCatalog) RequestRecord { 277 + rec = normalizeRequestRecord(rec) 278 + if pricing == nil || requestRecordHasCost(rec) { 279 + return rec 280 + } 281 + usage := uniaiapi.Usage{ 282 + InputTokens: int(rec.InputTokens), 283 + OutputTokens: int(rec.OutputTokens), 284 + TotalTokens: int(rec.TotalTokens), 285 + Cache: uniaiapi.UsageCache{ 286 + CachedInputTokens: int(rec.CachedInputTokens), 287 + CacheCreationInputTokens: int(rec.CacheCreationInputTokens), 288 + Details: toIntMap(rec.CacheDetails), 289 + }, 290 + } 291 + cost, ok := pricing.EstimateChatCost(rec.Model, usage) 292 + if !ok || cost == nil { 293 + return rec 294 + } 295 + rec.CostCurrency = cost.Currency 296 + rec.CostEstimated = cost.Estimated 297 + rec.InputCost = cost.Input 298 + rec.CachedInputCost = cost.CachedInput 299 + rec.CacheCreationInputCost = cost.CacheCreationInput 300 + rec.OutputCost = cost.Output 301 + rec.TotalCost = cost.Total 302 + return normalizeRequestRecord(rec) 303 + } 304 + 305 + func requestRecordHasCost(rec RequestRecord) bool { 306 + return strings.TrimSpace(rec.CostCurrency) != "" || 307 + rec.CostEstimated || 308 + rec.InputCost != 0 || 309 + rec.CachedInputCost != 0 || 310 + rec.CacheCreationInputCost != 0 || 311 + rec.OutputCost != 0 || 312 + rec.TotalCost != 0 313 + } 314 + 315 + func toIntMap(in map[string]int64) map[string]int { 316 + if len(in) == 0 { 317 + return nil 318 + } 319 + out := make(map[string]int, len(in)) 320 + for key, value := range in { 321 + out[key] = int(nonNegative(value)) 322 + } 323 + return out 193 324 } 194 325 195 326 func offsetValidForSegments(dir string, segments []journalSegmentFile, off Offset) bool {
+150 -7
internal/llmstats/projection_test.go
··· 5 5 "path/filepath" 6 6 "testing" 7 7 "time" 8 + 9 + uniaiapi "github.com/quailyquaily/uniai" 8 10 ) 9 11 10 12 func TestProjectionRefreshAggregatesAndReplaysTail(t *testing.T) { ··· 22 24 appendRecord := func(host, model string, input, output int64) { 23 25 t.Helper() 24 26 _, err := journal.Append(RequestRecord{ 25 - TS: time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC).Format(time.RFC3339), 26 - Provider: "openai", 27 - APIBase: "https://" + host, 28 - Model: model, 29 - InputTokens: input, 30 - OutputTokens: output, 31 - TotalTokens: input + output, 27 + TS: time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC).Format(time.RFC3339), 28 + Provider: "openai", 29 + APIBase: "https://" + host, 30 + Model: model, 31 + InputTokens: input, 32 + OutputTokens: output, 33 + TotalTokens: input + output, 34 + CachedInputTokens: input / 2, 35 + CacheCreationInputTokens: output / 2, 36 + CacheDetails: map[string]int64{ 37 + "ephemeral_5m_input_tokens": output / 2, 38 + }, 39 + CostCurrency: "USD", 40 + CostEstimated: true, 41 + InputCost: float64(input) / 1000, 42 + CachedInputCost: float64(input/2) / 1000, 43 + CacheCreationInputCost: float64(output/2) / 1000, 44 + OutputCost: float64(output) / 1000, 45 + TotalCost: float64(input+output) / 1000, 32 46 }) 33 47 if err != nil { 34 48 t.Fatalf("Append(%s,%s) error = %v", host, model, err) ··· 49 63 if proj.Summary.Requests != 2 || proj.Summary.TotalTokens != 45 { 50 64 t.Fatalf("projection1 summary = %+v, want requests=2 total_tokens=45", proj.Summary) 51 65 } 66 + if proj.Summary.CachedInputTokens != 15 || proj.Summary.CacheCreationInputTokens != 7 { 67 + t.Fatalf("projection1 cache totals = %+v", proj.Summary) 68 + } 69 + if proj.Summary.CostCurrency != "USD" || !costAlmostEqual(proj.Summary.TotalCost, 0.045) { 70 + t.Fatalf("projection1 cost totals = %+v", proj.Summary) 71 + } 52 72 if len(proj.APIHosts) != 1 || proj.APIHosts[0].APIHost != "api.openai.com" { 53 73 t.Fatalf("projection1 hosts = %+v", proj.APIHosts) 54 74 } ··· 63 83 } 64 84 if proj.Summary.Requests != 3 || proj.Summary.TotalTokens != 50 { 65 85 t.Fatalf("projection2 summary = %+v, want requests=3 total_tokens=50", proj.Summary) 86 + } 87 + if proj.Summary.CachedInputTokens != 16 || proj.Summary.CacheCreationInputTokens != 8 { 88 + t.Fatalf("projection2 cache totals = %+v", proj.Summary) 89 + } 90 + if !costAlmostEqual(proj.Summary.TotalCost, 0.05) { 91 + t.Fatalf("projection2 cost totals = %+v", proj.Summary) 66 92 } 67 93 if proj.ProjectedOffset.File == "" || proj.ProjectedOffset.Line != 3 { 68 94 t.Fatalf("projection2 offset = %+v, want line 3", proj.ProjectedOffset) ··· 96 122 t.Fatalf("projection offset = %+v, want first line only", proj.ProjectedOffset) 97 123 } 98 124 } 125 + 126 + func TestProjectionRefreshBackfillsLegacyCostFromPricing(t *testing.T) { 127 + t.Parallel() 128 + 129 + root := t.TempDir() 130 + journalDir := filepath.Join(root, "journal") 131 + projectionPath := filepath.Join(root, "projection.json") 132 + journal := NewJournal(journalDir, JournalOptions{MaxFileBytes: 1024 * 1024}) 133 + defer func() { _ = journal.Close() }() 134 + 135 + if _, err := journal.Append(RequestRecord{ 136 + TS: time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC).Format(time.RFC3339), 137 + Provider: "openai", 138 + APIBase: "https://api.openai.com", 139 + Model: "gpt-5.4", 140 + InputTokens: 1000, 141 + OutputTokens: 2000, 142 + TotalTokens: 3000, 143 + }); err != nil { 144 + t.Fatalf("Append() error = %v", err) 145 + } 146 + 147 + pricing := mustParsePricingCatalog(t, ` 148 + version: uniai.pricing.v1 149 + chat: 150 + - inference_provider: openai 151 + model: gpt-5.4 152 + input_usd_per_million: 1 153 + output_usd_per_million: 2 154 + `) 155 + store := NewProjectionStore(journalDir, projectionPath) 156 + store.loadPricing = func() (*uniaiapi.PricingCatalog, string, error) { 157 + return pricing, "digest-a", nil 158 + } 159 + 160 + proj, err := store.Refresh() 161 + if err != nil { 162 + t.Fatalf("Refresh() error = %v", err) 163 + } 164 + if proj.Summary.CostCurrency != "USD" || !costAlmostEqual(proj.Summary.TotalCost, 0.005) { 165 + t.Fatalf("projection summary cost = %+v", proj.Summary) 166 + } 167 + if proj.Summary.InputCost != 0.001 || proj.Summary.OutputCost != 0.004 { 168 + t.Fatalf("projection summary breakdown = %+v", proj.Summary) 169 + } 170 + } 171 + 172 + func TestProjectionRefreshRebuildsWhenPricingDigestChanges(t *testing.T) { 173 + t.Parallel() 174 + 175 + root := t.TempDir() 176 + journalDir := filepath.Join(root, "journal") 177 + projectionPath := filepath.Join(root, "projection.json") 178 + journal := NewJournal(journalDir, JournalOptions{MaxFileBytes: 1024 * 1024}) 179 + defer func() { _ = journal.Close() }() 180 + 181 + if _, err := journal.Append(RequestRecord{ 182 + TS: time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC).Format(time.RFC3339), 183 + Provider: "openai", 184 + APIBase: "https://api.openai.com", 185 + Model: "gpt-5.4", 186 + InputTokens: 1000, 187 + OutputTokens: 2000, 188 + TotalTokens: 3000, 189 + }); err != nil { 190 + t.Fatalf("Append() error = %v", err) 191 + } 192 + 193 + store := NewProjectionStore(journalDir, projectionPath) 194 + store.loadPricing = func() (*uniaiapi.PricingCatalog, string, error) { 195 + return mustParsePricingCatalog(t, ` 196 + version: uniai.pricing.v1 197 + chat: 198 + - inference_provider: openai 199 + model: gpt-5.4 200 + input_usd_per_million: 1 201 + output_usd_per_million: 2 202 + `), "digest-a", nil 203 + } 204 + proj, err := store.Refresh() 205 + if err != nil { 206 + t.Fatalf("Refresh(1) error = %v", err) 207 + } 208 + if !costAlmostEqual(proj.Summary.TotalCost, 0.005) { 209 + t.Fatalf("projection1 summary cost = %+v", proj.Summary) 210 + } 211 + 212 + store.loadPricing = func() (*uniaiapi.PricingCatalog, string, error) { 213 + return mustParsePricingCatalog(t, ` 214 + version: uniai.pricing.v1 215 + chat: 216 + - inference_provider: openai 217 + model: gpt-5.4 218 + input_usd_per_million: 2 219 + output_usd_per_million: 3 220 + `), "digest-b", nil 221 + } 222 + proj, err = store.Refresh() 223 + if err != nil { 224 + t.Fatalf("Refresh(2) error = %v", err) 225 + } 226 + if !costAlmostEqual(proj.Summary.TotalCost, 0.008) { 227 + t.Fatalf("projection2 summary cost = %+v", proj.Summary) 228 + } 229 + if proj.PricingDigest != "digest-b" { 230 + t.Fatalf("projection2 pricing digest = %q, want digest-b", proj.PricingDigest) 231 + } 232 + } 233 + 234 + func mustParsePricingCatalog(t *testing.T, yamlText string) *uniaiapi.PricingCatalog { 235 + t.Helper() 236 + pricing, err := uniaiapi.ParsePricingYAML([]byte(yamlText)) 237 + if err != nil { 238 + t.Fatalf("ParsePricingYAML() error = %v", err) 239 + } 240 + return pricing 241 + }
+105 -16
internal/llmstats/types.go
··· 20 20 } 21 21 22 22 type RequestRecord struct { 23 - TS string `json:"ts"` 24 - RunID string `json:"run_id,omitempty"` 25 - OriginEventID string `json:"origin_event_id,omitempty"` 26 - Provider string `json:"provider"` 27 - APIBase string `json:"api_base,omitempty"` 28 - APIHost string `json:"api_host"` 29 - Model string `json:"model"` 30 - Scene string `json:"scene,omitempty"` 31 - InputTokens int64 `json:"input_tokens"` 32 - OutputTokens int64 `json:"output_tokens"` 33 - TotalTokens int64 `json:"total_tokens"` 34 - DurationMs int64 `json:"duration_ms,omitempty"` 23 + TS string `json:"ts"` 24 + RunID string `json:"run_id,omitempty"` 25 + OriginEventID string `json:"origin_event_id,omitempty"` 26 + Provider string `json:"provider"` 27 + APIBase string `json:"api_base,omitempty"` 28 + APIHost string `json:"api_host"` 29 + Model string `json:"model"` 30 + Scene string `json:"scene,omitempty"` 31 + InputTokens int64 `json:"input_tokens"` 32 + OutputTokens int64 `json:"output_tokens"` 33 + TotalTokens int64 `json:"total_tokens"` 34 + CachedInputTokens int64 `json:"cached_input_tokens,omitempty"` 35 + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens,omitempty"` 36 + CacheDetails map[string]int64 `json:"cache_details,omitempty"` 37 + CostCurrency string `json:"cost_currency,omitempty"` 38 + CostEstimated bool `json:"cost_estimated,omitempty"` 39 + InputCost float64 `json:"input_cost,omitempty"` 40 + CachedInputCost float64 `json:"cached_input_cost,omitempty"` 41 + CacheCreationInputCost float64 `json:"cache_creation_input_cost,omitempty"` 42 + OutputCost float64 `json:"output_cost,omitempty"` 43 + TotalCost float64 `json:"total_cost,omitempty"` 44 + DurationMs int64 `json:"duration_ms,omitempty"` 35 45 } 36 46 37 47 type Totals struct { 38 - Requests int64 `json:"requests"` 39 - InputTokens int64 `json:"input_tokens"` 40 - OutputTokens int64 `json:"output_tokens"` 41 - TotalTokens int64 `json:"total_tokens"` 48 + Requests int64 `json:"requests"` 49 + InputTokens int64 `json:"input_tokens"` 50 + OutputTokens int64 `json:"output_tokens"` 51 + TotalTokens int64 `json:"total_tokens"` 52 + CachedInputTokens int64 `json:"cached_input_tokens,omitempty"` 53 + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens,omitempty"` 54 + CacheDetails map[string]int64 `json:"cache_details,omitempty"` 55 + CostCurrency string `json:"cost_currency,omitempty"` 56 + CostEstimated bool `json:"cost_estimated,omitempty"` 57 + InputCost float64 `json:"input_cost,omitempty"` 58 + CachedInputCost float64 `json:"cached_input_cost,omitempty"` 59 + CacheCreationInputCost float64 `json:"cache_creation_input_cost,omitempty"` 60 + OutputCost float64 `json:"output_cost,omitempty"` 61 + TotalCost float64 `json:"total_cost,omitempty"` 42 62 } 43 63 44 64 type ModelSummary struct { ··· 53 73 } 54 74 55 75 type Projection struct { 76 + SchemaVersion int `json:"schema_version,omitempty"` 77 + PricingDigest string `json:"pricing_digest,omitempty"` 56 78 UpdatedAt string `json:"updated_at,omitempty"` 57 79 ProjectedOffset Offset `json:"projected_offset,omitempty"` 58 80 ProjectedRecords int64 `json:"projected_records,omitempty"` ··· 70 92 t.InputTokens += nonNegative(rec.InputTokens) 71 93 t.OutputTokens += nonNegative(rec.OutputTokens) 72 94 t.TotalTokens += nonNegative(rec.TotalTokens) 95 + t.CachedInputTokens += nonNegative(rec.CachedInputTokens) 96 + t.CacheCreationInputTokens += nonNegative(rec.CacheCreationInputTokens) 97 + t.CostCurrency = mergeCostCurrency(t.CostCurrency, rec.CostCurrency) 98 + t.CostEstimated = t.CostEstimated || rec.CostEstimated 99 + t.InputCost += nonNegativeFloat(rec.InputCost) 100 + t.CachedInputCost += nonNegativeFloat(rec.CachedInputCost) 101 + t.CacheCreationInputCost += nonNegativeFloat(rec.CacheCreationInputCost) 102 + t.OutputCost += nonNegativeFloat(rec.OutputCost) 103 + t.TotalCost += nonNegativeFloat(rec.TotalCost) 104 + if len(rec.CacheDetails) > 0 { 105 + if t.CacheDetails == nil { 106 + t.CacheDetails = map[string]int64{} 107 + } 108 + for key, value := range rec.CacheDetails { 109 + t.CacheDetails[key] += nonNegative(value) 110 + } 111 + } 73 112 } 74 113 75 114 func normalizeRequestRecord(rec RequestRecord) RequestRecord { ··· 87 126 rec.InputTokens = nonNegative(rec.InputTokens) 88 127 rec.OutputTokens = nonNegative(rec.OutputTokens) 89 128 rec.TotalTokens = nonNegative(rec.TotalTokens) 129 + rec.CachedInputTokens = nonNegative(rec.CachedInputTokens) 130 + rec.CacheCreationInputTokens = nonNegative(rec.CacheCreationInputTokens) 131 + rec.CacheDetails = normalizeCacheDetails(rec.CacheDetails) 132 + rec.CostCurrency = normalizeCostCurrency(rec.CostCurrency) 133 + rec.InputCost = nonNegativeFloat(rec.InputCost) 134 + rec.CachedInputCost = nonNegativeFloat(rec.CachedInputCost) 135 + rec.CacheCreationInputCost = nonNegativeFloat(rec.CacheCreationInputCost) 136 + rec.OutputCost = nonNegativeFloat(rec.OutputCost) 137 + rec.TotalCost = nonNegativeFloat(rec.TotalCost) 90 138 if rec.TotalTokens == 0 { 91 139 rec.TotalTokens = rec.InputTokens + rec.OutputTokens 92 140 } ··· 183 231 return 0 184 232 } 185 233 return v 234 + } 235 + 236 + func nonNegativeFloat(v float64) float64 { 237 + if v < 0 { 238 + return 0 239 + } 240 + return v 241 + } 242 + 243 + func normalizeCacheDetails(in map[string]int64) map[string]int64 { 244 + if len(in) == 0 { 245 + return nil 246 + } 247 + out := make(map[string]int64, len(in)) 248 + for key, value := range in { 249 + key = strings.TrimSpace(key) 250 + if key == "" { 251 + continue 252 + } 253 + out[key] = nonNegative(value) 254 + } 255 + if len(out) == 0 { 256 + return nil 257 + } 258 + return out 259 + } 260 + 261 + func normalizeCostCurrency(currency string) string { 262 + return strings.ToUpper(strings.TrimSpace(currency)) 263 + } 264 + 265 + func mergeCostCurrency(current, next string) string { 266 + current = normalizeCostCurrency(current) 267 + next = normalizeCostCurrency(next) 268 + if current == "" { 269 + return next 270 + } 271 + if next == "" || current == next { 272 + return current 273 + } 274 + return "MIXED" 186 275 } 187 276 188 277 func sortModelSummaries(items []ModelSummary) {
+15
internal/llmutil/llmutil.go
··· 8 8 "time" 9 9 10 10 "github.com/quailyquaily/mistermorph/internal/llmconfig" 11 + "github.com/quailyquaily/mistermorph/internal/pricingutil" 11 12 "github.com/quailyquaily/mistermorph/llm" 12 13 uniaiProvider "github.com/quailyquaily/mistermorph/providers/uniai" 14 + uniaiapi "github.com/quailyquaily/uniai" 13 15 "github.com/spf13/viper" 14 16 ) 15 17 ··· 29 31 TemperatureRaw string `config:"llm.temperature"` 30 32 ReasoningEffortRaw string `config:"llm.reasoning_effort"` 31 33 ReasoningBudgetRaw string `config:"llm.reasoning_budget_tokens"` 34 + PricingFile string `config:"llm.pricing_file"` 35 + ConfigPath string `config:"config"` 32 36 Profiles map[string]ProfileConfig 33 37 Routes RoutesConfig 34 38 ··· 56 60 TemperatureRaw: strings.TrimSpace(r.GetString("llm.temperature")), 57 61 ReasoningEffortRaw: strings.TrimSpace(r.GetString("llm.reasoning_effort")), 58 62 ReasoningBudgetRaw: strings.TrimSpace(r.GetString("llm.reasoning_budget_tokens")), 63 + PricingFile: strings.TrimSpace(r.GetString("llm.pricing_file")), 64 + ConfigPath: strings.TrimSpace(r.GetString("config")), 59 65 Profiles: loadLLMProfilesFromReader(r), 60 66 Routes: loadLLMRoutesFromReader(r), 61 67 BedrockAWSKey: firstNonEmpty(r.GetString("llm.bedrock.aws_key"), r.GetString("llm.aws.key")), ··· 133 139 if err != nil { 134 140 return nil, err 135 141 } 142 + pricing, _, err := LoadPricingCatalog(values) 143 + if err != nil { 144 + return nil, err 145 + } 136 146 provider := strings.ToLower(strings.TrimSpace(cfg.Provider)) 137 147 if provider == "openai_resp" && reasoningBudget != nil { 138 148 slog.Warn("llm_reasoning_budget_ignored", "provider", provider, "field", "llm.reasoning_budget_tokens") ··· 145 155 APIKey: strings.TrimSpace(cfg.APIKey), 146 156 Model: strings.TrimSpace(cfg.Model), 147 157 Headers: cloneStringMap(cfg.Headers), 158 + Pricing: pricing, 148 159 RequestTimeout: cfg.RequestTimeout, 149 160 ToolsEmulationMode: toolsEmulationMode, 150 161 Temperature: temperature, ··· 170 181 default: 171 182 return nil, fmt.Errorf("unknown provider: %s", cfg.Provider) 172 183 } 184 + } 185 + 186 + func LoadPricingCatalog(values RuntimeValues) (*uniaiapi.PricingCatalog, string, error) { 187 + return pricingutil.LoadCatalog(values.PricingFile, values.ConfigPath) 173 188 } 174 189 175 190 type BaseClientBuilder func(cfg llmconfig.ClientConfig, values RuntimeValues) (llm.Client, error)
+85
internal/llmutil/llmutil_test.go
··· 1 1 package llmutil 2 2 3 3 import ( 4 + "os" 5 + "path/filepath" 4 6 "strings" 5 7 "testing" 6 8 "time" ··· 173 175 t.Fatalf("expected error for invalid reasoning budget") 174 176 } 175 177 if !strings.Contains(err.Error(), "llm.reasoning_budget_tokens") { 178 + t.Fatalf("unexpected error: %v", err) 179 + } 180 + } 181 + 182 + func TestPricingCatalogFromValues_ResolvesRelativeToConfigPath(t *testing.T) { 183 + dir := t.TempDir() 184 + configPath := filepath.Join(dir, "config.yaml") 185 + pricingPath := filepath.Join(dir, "pricing.yaml") 186 + if err := os.WriteFile(pricingPath, []byte("version: uniai.pricing.v1\nchat:\n - inference_provider: openai\n model: gpt-5.4\n input_usd_per_million: 1\n output_usd_per_million: 2\n"), 0o644); err != nil { 187 + t.Fatalf("WriteFile(pricing.yaml) error = %v", err) 188 + } 189 + 190 + pricing, digest, err := LoadPricingCatalog(RuntimeValues{ 191 + ConfigPath: configPath, 192 + PricingFile: "./pricing.yaml", 193 + }) 194 + if err != nil { 195 + t.Fatalf("LoadPricingCatalog() error = %v", err) 196 + } 197 + if pricing == nil || len(pricing.Chat) != 1 { 198 + t.Fatalf("pricing catalog = %#v, want one chat rule", pricing) 199 + } 200 + if strings.TrimSpace(digest) == "" { 201 + t.Fatalf("expected non-empty pricing digest") 202 + } 203 + if pricing.Chat[0].InferenceProvider != "openai" || pricing.Chat[0].Model != "gpt-5.4" { 204 + t.Fatalf("pricing rule = %#v", pricing.Chat[0]) 205 + } 206 + } 207 + 208 + func TestPricingCatalogFromValues_MissingFileFallsBackToDefault(t *testing.T) { 209 + dir := t.TempDir() 210 + 211 + pricing, digest, err := LoadPricingCatalog(RuntimeValues{ 212 + ConfigPath: filepath.Join(dir, "config.yaml"), 213 + PricingFile: "./pricing.yaml", 214 + }) 215 + if err != nil { 216 + t.Fatalf("LoadPricingCatalog() error = %v", err) 217 + } 218 + if pricing == nil { 219 + t.Fatalf("expected default pricing catalog") 220 + } 221 + if len(pricing.Chat) == 0 { 222 + t.Fatalf("expected default pricing catalog to include chat rules") 223 + } 224 + if strings.TrimSpace(digest) == "" { 225 + t.Fatalf("expected non-empty pricing digest") 226 + } 227 + } 228 + 229 + func TestPricingCatalogFromValues_EmptyPathFallsBackToDefault(t *testing.T) { 230 + pricing, digest, err := LoadPricingCatalog(RuntimeValues{}) 231 + if err != nil { 232 + t.Fatalf("LoadPricingCatalog() error = %v", err) 233 + } 234 + if pricing == nil { 235 + t.Fatalf("expected default pricing catalog") 236 + } 237 + if len(pricing.Chat) == 0 { 238 + t.Fatalf("expected default pricing catalog to include chat rules") 239 + } 240 + if strings.TrimSpace(digest) == "" { 241 + t.Fatalf("expected non-empty pricing digest") 242 + } 243 + } 244 + 245 + func TestPricingCatalogFromValues_InvalidYAML(t *testing.T) { 246 + dir := t.TempDir() 247 + configPath := filepath.Join(dir, "config.yaml") 248 + pricingPath := filepath.Join(dir, "pricing.yaml") 249 + if err := os.WriteFile(pricingPath, []byte("version: ["), 0o644); err != nil { 250 + t.Fatalf("WriteFile(pricing.yaml) error = %v", err) 251 + } 252 + 253 + _, _, err := LoadPricingCatalog(RuntimeValues{ 254 + ConfigPath: configPath, 255 + PricingFile: "./pricing.yaml", 256 + }) 257 + if err == nil { 258 + t.Fatalf("expected parse error for invalid pricing yaml") 259 + } 260 + if !strings.Contains(err.Error(), "llm.pricing_file") { 176 261 t.Fatalf("unexpected error: %v", err) 177 262 } 178 263 }
+70
internal/pricingutil/catalog.go
··· 1 + package pricingutil 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/hex" 6 + "encoding/json" 7 + "fmt" 8 + "os" 9 + "path/filepath" 10 + "strings" 11 + 12 + uniaiapi "github.com/quailyquaily/uniai" 13 + ) 14 + 15 + func LoadCatalog(path, configPath string) (*uniaiapi.PricingCatalog, string, error) { 16 + path = strings.TrimSpace(path) 17 + if path == "" { 18 + return defaultCatalog() 19 + } 20 + resolved := resolveConfigRelativePath(path, configPath) 21 + data, err := os.ReadFile(resolved) 22 + if err != nil { 23 + if os.IsNotExist(err) { 24 + return defaultCatalog() 25 + } 26 + return nil, "", fmt.Errorf("read llm.pricing_file %q: %w", path, err) 27 + } 28 + pricing, err := uniaiapi.ParsePricingYAML(data) 29 + if err != nil { 30 + return nil, "", fmt.Errorf("parse llm.pricing_file %q: %w", path, err) 31 + } 32 + sum := sha256.Sum256(data) 33 + return pricing, hex.EncodeToString(sum[:]), nil 34 + } 35 + 36 + func resolveConfigRelativePath(path, configPath string) string { 37 + path = strings.TrimSpace(path) 38 + if path == "" { 39 + return "" 40 + } 41 + if filepath.IsAbs(path) { 42 + return filepath.Clean(path) 43 + } 44 + configPath = strings.TrimSpace(configPath) 45 + if configPath == "" { 46 + return filepath.Clean(path) 47 + } 48 + return filepath.Clean(filepath.Join(filepath.Dir(configPath), path)) 49 + } 50 + 51 + func defaultCatalog() (*uniaiapi.PricingCatalog, string, error) { 52 + catalog := uniaiapi.DefaultPricingCatalog() 53 + digest, err := catalogDigest(catalog) 54 + if err != nil { 55 + return nil, "", err 56 + } 57 + return catalog, digest, nil 58 + } 59 + 60 + func catalogDigest(catalog *uniaiapi.PricingCatalog) (string, error) { 61 + if catalog == nil { 62 + return "", nil 63 + } 64 + data, err := json.Marshal(catalog) 65 + if err != nil { 66 + return "", fmt.Errorf("marshal pricing catalog digest: %w", err) 67 + } 68 + sum := sha256.Sum256(data) 69 + return hex.EncodeToString(sum[:]), nil 70 + }
+27 -9
llm/llm.go
··· 46 46 InputTokens int 47 47 OutputTokens int 48 48 TotalTokens int 49 - Cost float64 // USD 49 + Cache UsageCache 50 + Cost *UsageCost 51 + } 52 + 53 + type UsageCache struct { 54 + CachedInputTokens int 55 + CacheCreationInputTokens int 56 + Details map[string]int 57 + } 58 + 59 + type UsageCost struct { 60 + Currency string 61 + Estimated bool 62 + Input float64 63 + CachedInput float64 64 + CacheCreationInput float64 65 + Output float64 66 + Total float64 50 67 } 51 68 52 69 type StreamToolCallDelta struct { ··· 75 92 } 76 93 77 94 type Request struct { 78 - Model string 79 - Scene string 80 - Messages []Message 81 - Tools []Tool 82 - ForceJSON bool 83 - Parameters map[string]any 84 - DebugFn func(label, payload string) 85 - OnStream StreamHandler 95 + Model string 96 + InferenceProvider string 97 + Scene string 98 + Messages []Message 99 + Tools []Tool 100 + ForceJSON bool 101 + Parameters map[string]any 102 + DebugFn func(label, payload string) 103 + OnStream StreamHandler 86 104 } 87 105 88 106 type Client interface {
+22 -6
llm/llm_test.go
··· 7 7 InputTokens: 100, 8 8 OutputTokens: 50, 9 9 TotalTokens: 150, 10 - Cost: 0.05, 10 + Cost: &UsageCost{ 11 + Currency: "USD", 12 + Total: 0.05, 13 + }, 11 14 } 12 - if u.Cost != 0.05 { 13 - t.Errorf("expected Cost 0.05, got %f", u.Cost) 15 + if u.Cost == nil || u.Cost.Total != 0.05 { 16 + t.Fatalf("expected Cost.Total 0.05, got %#v", u.Cost) 14 17 } 15 18 } 16 19 17 - func TestUsageCostDefaultsToZero(t *testing.T) { 20 + func TestUsageCostDefaultsToNil(t *testing.T) { 18 21 u := Usage{} 19 - if u.Cost != 0 { 20 - t.Errorf("expected Cost to default to 0, got %f", u.Cost) 22 + if u.Cost != nil { 23 + t.Fatalf("expected Cost to default to nil, got %#v", u.Cost) 24 + } 25 + } 26 + 27 + func TestUsageCacheDefaultsToZeroValues(t *testing.T) { 28 + u := Usage{} 29 + if u.Cache.CachedInputTokens != 0 { 30 + t.Fatalf("expected CachedInputTokens=0, got %d", u.Cache.CachedInputTokens) 31 + } 32 + if u.Cache.CacheCreationInputTokens != 0 { 33 + t.Fatalf("expected CacheCreationInputTokens=0, got %d", u.Cache.CacheCreationInputTokens) 34 + } 35 + if len(u.Cache.Details) != 0 { 36 + t.Fatalf("expected Details empty, got %#v", u.Cache.Details) 21 37 } 22 38 }
+53 -11
providers/uniai/client.go
··· 22 22 APIKey string 23 23 Model string 24 24 Headers map[string]string 25 + Pricing *uniaiapi.PricingCatalog 25 26 26 27 RequestTimeout time.Duration 27 28 Temperature *float64 ··· 76 77 OpenAIAPIBase: openAIBase, 77 78 OpenAIModel: strings.TrimSpace(cfg.Model), 78 79 ChatHeaders: cloneStringMap(cfg.Headers), 80 + Pricing: cfg.Pricing, 79 81 AzureOpenAIAPIKey: strings.TrimSpace(azureAPIKey), 80 82 AzureOpenAIEndpoint: strings.TrimSpace(azureEndpoint), 81 83 AzureOpenAIModel: strings.TrimSpace(azureDeployment), ··· 145 147 Text: resp.Text, 146 148 Parts: toLLMParts(resp.Parts), 147 149 ToolCalls: toolCalls, 148 - Usage: llm.Usage{ 149 - InputTokens: resp.Usage.InputTokens, 150 - OutputTokens: resp.Usage.OutputTokens, 151 - TotalTokens: resp.Usage.TotalTokens, 152 - }, 153 - Duration: time.Since(start), 150 + Usage: toLLMUsage(resp.Usage), 151 + Duration: time.Since(start), 154 152 }, nil 155 153 } 156 154 ··· 180 178 } 181 179 if strings.TrimSpace(req.Model) != "" { 182 180 opts = append(opts, uniaiapi.WithModel(strings.TrimSpace(req.Model))) 181 + } 182 + if strings.TrimSpace(req.InferenceProvider) != "" { 183 + opts = append(opts, uniaiapi.WithInferenceProvider(strings.TrimSpace(req.InferenceProvider))) 183 184 } 184 185 185 186 if len(req.Tools) > 0 { ··· 263 264 } 264 265 } 265 266 if ev.Usage != nil { 266 - streamEvent.Usage = &llm.Usage{ 267 - InputTokens: ev.Usage.InputTokens, 268 - OutputTokens: ev.Usage.OutputTokens, 269 - TotalTokens: ev.Usage.TotalTokens, 270 - } 267 + usage := toLLMUsage(*ev.Usage) 268 + streamEvent.Usage = &usage 271 269 } 272 270 return req.OnStream(streamEvent) 273 271 })) ··· 306 304 return nil 307 305 } 308 306 out := make(map[string]string, len(in)) 307 + for key, value := range in { 308 + out[key] = value 309 + } 310 + return out 311 + } 312 + 313 + func toLLMUsage(usage uniaichat.Usage) llm.Usage { 314 + return llm.Usage{ 315 + InputTokens: usage.InputTokens, 316 + OutputTokens: usage.OutputTokens, 317 + TotalTokens: usage.TotalTokens, 318 + Cache: toLLMUsageCache(usage.Cache), 319 + Cost: toLLMUsageCost(usage.Cost), 320 + } 321 + } 322 + 323 + func toLLMUsageCache(cache uniaichat.UsageCache) llm.UsageCache { 324 + return llm.UsageCache{ 325 + CachedInputTokens: cache.CachedInputTokens, 326 + CacheCreationInputTokens: cache.CacheCreationInputTokens, 327 + Details: cloneIntMap(cache.Details), 328 + } 329 + } 330 + 331 + func toLLMUsageCost(cost *uniaichat.UsageCost) *llm.UsageCost { 332 + if cost == nil { 333 + return nil 334 + } 335 + return &llm.UsageCost{ 336 + Currency: cost.Currency, 337 + Estimated: cost.Estimated, 338 + Input: cost.Input, 339 + CachedInput: cost.CachedInput, 340 + CacheCreationInput: cost.CacheCreationInput, 341 + Output: cost.Output, 342 + Total: cost.Total, 343 + } 344 + } 345 + 346 + func cloneIntMap(in map[string]int) map[string]int { 347 + if len(in) == 0 { 348 + return nil 349 + } 350 + out := make(map[string]int, len(in)) 309 351 for key, value := range in { 310 352 out[key] = value 311 353 }
+86
providers/uniai/client_test.go
··· 89 89 } 90 90 } 91 91 92 + func TestBuildChatOptionsMapsInferenceProvider(t *testing.T) { 93 + req := llm.Request{ 94 + Model: "gpt-5.4", 95 + InferenceProvider: "openai", 96 + Messages: []llm.Message{ 97 + {Role: "user", Content: "hello"}, 98 + }, 99 + } 100 + 101 + opts := buildChatOptions(req, "", false, uniaiapi.ToolsEmulationOff, nil, "", nil) 102 + built, err := uniaichat.BuildRequest(opts...) 103 + if err != nil { 104 + t.Fatalf("build request: %v", err) 105 + } 106 + if built.InferenceProvider != "openai" { 107 + t.Fatalf("inference_provider = %q, want openai", built.InferenceProvider) 108 + } 109 + } 110 + 92 111 func TestBuildChatOptionsMapsOnStream(t *testing.T) { 93 112 called := false 94 113 req := llm.Request{ ··· 107 126 if ev.Usage == nil || ev.Usage.TotalTokens != 9 { 108 127 t.Fatalf("usage = %#v", ev.Usage) 109 128 } 129 + if ev.Usage.Cache.CachedInputTokens != 3 { 130 + t.Fatalf("cached_input_tokens = %d, want 3", ev.Usage.Cache.CachedInputTokens) 131 + } 132 + if ev.Usage.Cache.CacheCreationInputTokens != 2 { 133 + t.Fatalf("cache_creation_input_tokens = %d, want 2", ev.Usage.Cache.CacheCreationInputTokens) 134 + } 135 + if got := ev.Usage.Cache.Details["ephemeral_5m_input_tokens"]; got != 2 { 136 + t.Fatalf("cache details = %#v", ev.Usage.Cache.Details) 137 + } 138 + if ev.Usage.Cost == nil || ev.Usage.Cost.Total != 0.125 { 139 + t.Fatalf("cost = %#v", ev.Usage.Cost) 140 + } 110 141 return nil 111 142 }, 112 143 } ··· 131 162 InputTokens: 4, 132 163 OutputTokens: 5, 133 164 TotalTokens: 9, 165 + Cache: uniaichat.UsageCache{ 166 + CachedInputTokens: 3, 167 + CacheCreationInputTokens: 2, 168 + Details: map[string]int{ 169 + "ephemeral_5m_input_tokens": 2, 170 + }, 171 + }, 172 + Cost: &uniaichat.UsageCost{ 173 + Currency: "USD", 174 + Estimated: true, 175 + CachedInput: 0.025, 176 + CacheCreationInput: 0.050, 177 + Output: 0.050, 178 + Total: 0.125, 179 + }, 134 180 }, 135 181 Done: true, 136 182 }); err != nil { ··· 138 184 } 139 185 if !called { 140 186 t.Fatalf("expected callback to be called") 187 + } 188 + } 189 + 190 + func TestToLLMUsageMapsCacheAndCost(t *testing.T) { 191 + usage := toLLMUsage(uniaichat.Usage{ 192 + InputTokens: 10, 193 + OutputTokens: 5, 194 + TotalTokens: 15, 195 + Cache: uniaichat.UsageCache{ 196 + CachedInputTokens: 4, 197 + CacheCreationInputTokens: 3, 198 + Details: map[string]int{ 199 + "ephemeral_5m_input_tokens": 3, 200 + }, 201 + }, 202 + Cost: &uniaichat.UsageCost{ 203 + Currency: "USD", 204 + Estimated: true, 205 + Input: 0.01, 206 + CachedInput: 0.002, 207 + CacheCreationInput: 0.003, 208 + Output: 0.02, 209 + Total: 0.035, 210 + }, 211 + }) 212 + 213 + if usage.InputTokens != 10 || usage.OutputTokens != 5 || usage.TotalTokens != 15 { 214 + t.Fatalf("usage tokens = %#v", usage) 215 + } 216 + if usage.Cache.CachedInputTokens != 4 || usage.Cache.CacheCreationInputTokens != 3 { 217 + t.Fatalf("usage cache = %#v", usage.Cache) 218 + } 219 + if got := usage.Cache.Details["ephemeral_5m_input_tokens"]; got != 3 { 220 + t.Fatalf("usage cache details = %#v", usage.Cache.Details) 221 + } 222 + if usage.Cost == nil { 223 + t.Fatalf("expected cost to be mapped") 224 + } 225 + if usage.Cost.Total != 0.035 || usage.Cost.CachedInput != 0.002 || usage.Cost.CacheCreationInput != 0.003 { 226 + t.Fatalf("usage cost = %#v", usage.Cost) 141 227 } 142 228 } 143 229
+27
web/console/src/i18n/index.js
··· 181 181 stats_input_tokens: "Input Tokens", 182 182 stats_output_tokens: "Output Tokens", 183 183 stats_total_tokens: "Total Tokens", 184 + stats_cached_input_tokens: "Cached Input Tokens", 185 + stats_cache_creation_input_tokens: "Cache Write Tokens", 186 + stats_total_cost: "Total Cost", 187 + stats_costs: "Costs", 188 + stats_input_cost: "Input Cost", 189 + stats_output_cost: "Output Cost", 190 + stats_cached_input_cost: "Cached Input Cost", 191 + stats_cache_creation_input_cost: "Cache Write Cost", 192 + stats_tokens: "Tokens", 184 193 stats_api_host: "API Host", 185 194 stats_model: "Model", 186 195 stats_no_data: "No usage records", ··· 766 775 stats_input_tokens: "输入 Tokens", 767 776 stats_output_tokens: "输出 Tokens", 768 777 stats_total_tokens: "总 Tokens", 778 + stats_cached_input_tokens: "缓存命中 Tokens", 779 + stats_cache_creation_input_tokens: "缓存写入 Tokens", 780 + stats_total_cost: "总成本", 781 + stats_costs: "成本", 782 + stats_input_cost: "输入成本", 783 + stats_output_cost: "输出成本", 784 + stats_cached_input_cost: "缓存命中成本", 785 + stats_cache_creation_input_cost: "缓存写入成本", 786 + stats_tokens: "Tokens", 769 787 stats_api_host: "API Host", 770 788 stats_model: "模型", 771 789 stats_no_data: "暂无用量记录", ··· 1351 1369 stats_input_tokens: "入力 Tokens", 1352 1370 stats_output_tokens: "出力 Tokens", 1353 1371 stats_total_tokens: "合計 Tokens", 1372 + stats_cached_input_tokens: "キャッシュ入力 Tokens", 1373 + stats_cache_creation_input_tokens: "キャッシュ書き込み Tokens", 1374 + stats_total_cost: "合計コスト", 1375 + stats_costs: "コスト", 1376 + stats_input_cost: "入力コスト", 1377 + stats_output_cost: "出力コスト", 1378 + stats_cached_input_cost: "キャッシュ入力コスト", 1379 + stats_cache_creation_input_cost: "キャッシュ書き込みコスト", 1380 + stats_tokens: "Tokens", 1354 1381 stats_api_host: "API Host", 1355 1382 stats_model: "モデル", 1356 1383 stats_no_data: "使用量レコードなし",
+354 -167
web/console/src/views/StatsView.css
··· 1 - .stats-grid { 1 + .stats-page { 2 + --stats-accent: color-mix(in srgb, var(--accent-1) 68%, var(--text-0) 32%); 3 + --stats-ink: color-mix(in srgb, var(--text-0) 94%, var(--accent-1) 6%); 4 + --stats-copy: color-mix(in srgb, var(--text-1) 88%, var(--accent-1) 12%); 5 + --stats-muted: color-mix(in srgb, var(--text-1) 70%, var(--accent-1) 30%); 6 + --stats-line: color-mix(in srgb, var(--line) 74%, var(--accent-1) 26%); 7 + --stats-line-soft: color-mix(in srgb, var(--line-soft) 72%, var(--accent-1) 28%); 2 8 display: grid; 3 - gap: 18px; 9 + gap: clamp(20px, 2.6vw, 34px); 4 10 padding-bottom: 24px; 5 11 } 6 12 7 - .stats-section { 13 + .stats-hero { 14 + position: relative; 8 15 display: grid; 9 - gap: 10px; 16 + grid-template-columns: minmax(0, 0.88fr) minmax(340px, 1.12fr); 17 + gap: clamp(16px, 2.3vw, 34px); 18 + align-items: start; 19 + padding-top: 14px; 10 20 } 11 21 12 - .stats-section-tabs { 13 - justify-self: start; 22 + .stats-hero::before, 23 + .stats-host-block::before { 24 + content: ""; 25 + position: absolute; 26 + inset: 0 0 auto; 27 + height: 1px; 28 + background: linear-gradient( 29 + 90deg, 30 + transparent 0%, 31 + color-mix(in srgb, var(--stats-accent) 16%, transparent) 12%, 32 + color-mix(in srgb, var(--stats-accent) 42%, transparent) 48%, 33 + color-mix(in srgb, var(--stats-accent) 16%, transparent) 86%, 34 + transparent 100% 35 + ); 36 + opacity: 0.72; 37 + pointer-events: none; 14 38 } 15 39 16 - .stats-section-panel { 40 + .stats-hero-copy, 41 + .stats-host-block, 42 + .stats-section, 43 + .stats-section-panel, 44 + .stats-host-list, 45 + .stats-model-table { 46 + min-width: 0; 47 + } 48 + 49 + .stats-hero-copy { 17 50 display: grid; 18 - gap: 12px; 51 + gap: 14px; 52 + } 53 + 54 + .stats-hero-head, 55 + .stats-host-head { 56 + display: flex; 57 + align-items: flex-start; 58 + justify-content: space-between; 59 + gap: 14px; 60 + } 61 + 62 + .stats-hero-meta { 63 + margin: 0; 64 + display: flex; 65 + flex-wrap: wrap; 66 + justify-content: flex-end; 67 + gap: 8px 14px; 68 + font-family: var(--font-mono); 69 + font-size: 11px; 70 + line-height: 1.5; 71 + color: var(--stats-muted); 19 72 } 20 73 21 - .stats-section-panel-models.q-card-default { 22 - min-height: 0; 74 + .stats-hero-meta-item { 75 + white-space: nowrap; 23 76 } 24 77 25 - .stats-section-panel-models .q-card-content { 78 + .stats-lead-block { 26 79 display: grid; 27 - gap: 14px; 80 + gap: 8px; 81 + } 82 + 83 + .stats-lead-label, 84 + .stats-glance-label, 85 + .stats-host-eyebrow, 86 + .stats-ledger-label, 87 + .stats-model-head-cell, 88 + .stats-inline-meta-label, 89 + .stats-empty { 90 + font-family: var(--font-mono); 91 + font-size: 10px; 92 + font-weight: 500; 93 + letter-spacing: 0.12em; 94 + text-transform: uppercase; 95 + color: var(--stats-muted); 28 96 } 29 97 30 - .stats-section-panel .stats-model-list, 31 - .stats-section-panel .stats-empty { 32 - margin-top: 0; 98 + .stats-lead-value { 99 + display: block; 100 + font-family: var(--q-card-title-font-family); 101 + font-size: clamp(3rem, 5.7vw, 4.8rem); 102 + font-weight: 600; 103 + font-variant-numeric: tabular-nums; 104 + line-height: 0.94; 105 + letter-spacing: -0.045em; 106 + color: var(--stats-ink); 33 107 } 34 108 35 - .stats-summary-board { 109 + .stats-glance-grid { 36 110 display: grid; 111 + grid-template-columns: repeat(3, minmax(0, 1fr)); 112 + min-width: 0; 113 + border-top: 1px solid var(--stats-line-soft); 114 + border-left: 1px solid var(--stats-line-soft); 37 115 } 38 116 39 - .stats-summary-board.q-card-default { 40 - min-height: 0; 117 + .stats-glance-item { 118 + display: grid; 119 + gap: 6px; 120 + min-width: 0; 121 + padding: 14px 14px 13px; 122 + border-right: 1px solid var(--stats-line-soft); 123 + border-bottom: 1px solid var(--stats-line-soft); 41 124 } 42 125 43 - .stats-summary-board .q-card-content { 44 - display: grid; 45 - gap: 14px; 126 + .stats-glance-value, 127 + .stats-ledger-value { 128 + display: block; 129 + min-width: 0; 130 + font-family: var(--q-card-title-font-family); 131 + font-size: clamp(1.04rem, 0.98rem + 0.24vw, 1.22rem); 132 + font-weight: 600; 133 + font-variant-numeric: tabular-nums; 134 + line-height: 1.08; 135 + color: var(--stats-ink); 136 + overflow-wrap: anywhere; 46 137 } 47 138 48 - .stats-summary-head { 139 + .stats-inline-meta { 49 140 display: flex; 141 + flex-wrap: wrap; 142 + gap: 8px 18px; 50 143 align-items: baseline; 51 - justify-content: space-between; 144 + } 145 + 146 + .stats-inline-meta-summary { 147 + padding-top: 2px; 148 + } 149 + 150 + .stats-inline-meta-host, 151 + .stats-inline-meta-model { 152 + padding-top: 2px; 153 + } 154 + 155 + .stats-inline-meta-item { 156 + display: inline-flex; 157 + align-items: baseline; 158 + gap: 7px; 159 + min-width: 0; 160 + } 161 + 162 + .stats-inline-meta-value { 163 + font-size: 12px; 164 + line-height: 1.45; 165 + color: var(--stats-copy); 166 + font-variant-numeric: tabular-nums; 167 + overflow-wrap: anywhere; 168 + } 169 + 170 + .stats-section { 171 + display: grid; 52 172 gap: 12px; 53 173 } 54 174 55 - .stats-summary-meta { 56 - margin: 0; 57 - color: var(--text-2); 58 - font-size: 11px; 175 + .stats-section-tabs { 176 + justify-self: start; 59 177 } 60 178 61 - .stats-summary-grid { 179 + .stats-section-panel, 180 + .stats-host-list { 62 181 display: grid; 63 - grid-template-columns: minmax(220px, 0.96fr) minmax(0, 1.32fr); 64 - align-items: stretch; 65 - gap: 14px; 182 + gap: clamp(22px, 2.4vw, 32px); 66 183 } 67 184 68 - .stats-summary-lead { 185 + .stats-host-block { 186 + position: relative; 69 187 display: grid; 70 - align-content: center; 71 - gap: 8px; 72 - min-height: 0; 73 - padding: 6px 0; 188 + gap: 14px; 189 + padding-top: 16px; 74 190 } 75 191 76 - .stats-summary-rail { 192 + .stats-host-ident { 77 193 display: grid; 78 - grid-template-columns: repeat(3, minmax(0, 1fr)); 79 - align-items: stretch; 80 - gap: 10px; 194 + gap: 6px; 195 + min-width: 0; 81 196 } 82 197 83 - .stats-summary-label { 84 - display: block; 85 - color: var(--text-2); 198 + .stats-host-name, 199 + .stats-model-name { 200 + min-width: 0; 201 + font-family: var(--font-mono); 202 + font-size: 13px; 203 + font-weight: 600; 204 + line-height: 1.45; 205 + color: var(--stats-ink); 206 + overflow-wrap: anywhere; 207 + } 208 + 209 + .stats-request-pill { 210 + display: inline-grid; 211 + gap: 3px; 212 + justify-items: end; 213 + padding: 8px 10px 7px; 214 + border: 1px solid var(--stats-line); 215 + background: color-mix(in srgb, var(--bg-2) 92%, var(--accent-1) 8%); 216 + } 217 + 218 + .stats-request-pill-label { 219 + font-family: var(--font-mono); 86 220 font-size: 10px; 87 - letter-spacing: 0.08em; 221 + font-weight: 500; 222 + letter-spacing: 0.12em; 88 223 text-transform: uppercase; 224 + color: var(--stats-muted); 89 225 } 90 226 91 - .stats-summary-value { 92 - display: block; 93 - margin-top: auto; 227 + .stats-request-pill-value { 94 228 font-family: var(--q-card-title-font-family); 95 - font-size: clamp(2.35rem, 6.2vw, 4.2rem); 229 + font-size: 1.15rem; 96 230 font-weight: 600; 97 231 font-variant-numeric: tabular-nums; 98 - line-height: 0.98; 99 - letter-spacing: -0.04em; 100 - color: var(--text-0); 232 + line-height: 1; 233 + color: var(--stats-ink); 101 234 } 102 235 103 - .stats-summary-rail-item.q-card-tile { 104 - min-height: 0; 105 - height: 100%; 236 + .stats-request-pill-model { 237 + justify-items: start; 106 238 } 107 239 108 - .stats-summary-rail-item.q-card-tile .q-card-body, 109 - .stats-host-metric.q-card-tile .q-card-body { 110 - width: 100%; 111 - align-items: stretch; 112 - text-align: left; 113 - gap: 0; 240 + .stats-band { 241 + display: grid; 242 + gap: 10px; 114 243 } 115 244 116 - .stats-summary-rail-item.q-card-tile .q-card-content, 117 - .stats-host-metric.q-card-tile .q-card-content { 118 - width: 100%; 245 + .stats-band-head { 246 + display: inline-flex; 247 + align-items: center; 248 + gap: 8px; 249 + min-width: 0; 119 250 } 120 251 121 - .stats-summary-tile-copy, 122 - .stats-host-tile-copy { 123 - display: grid; 124 - gap: 6px; 125 - width: 100%; 252 + .stats-band-icon { 253 + width: 15px; 254 + min-width: 15px; 255 + height: 15px; 256 + color: var(--stats-accent); 126 257 } 127 258 128 - .stats-summary-tile-label, 129 - .stats-host-tile-label { 130 - display: block; 131 - color: var(--text-2); 259 + .stats-band-title { 260 + font-family: var(--font-mono); 132 261 font-size: 10px; 133 - letter-spacing: 0.08em; 262 + font-weight: 600; 263 + letter-spacing: 0.14em; 134 264 text-transform: uppercase; 265 + color: var(--stats-ink); 135 266 } 136 267 137 - .stats-summary-tile-value { 138 - display: block; 139 - font-family: var(--q-card-title-font-family); 140 - font-size: clamp(1.1rem, 1rem + 0.45vw, 1.55rem); 141 - font-weight: 600; 142 - font-variant-numeric: tabular-nums; 143 - line-height: 1.05; 144 - letter-spacing: -0.03em; 268 + .stats-band-grid { 269 + display: grid; 270 + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); 271 + min-width: 0; 272 + border-top: 1px solid var(--stats-line-soft); 273 + border-bottom: 1px solid var(--stats-line-soft); 145 274 } 146 275 147 - .stats-host-list { 276 + .stats-band-cell { 148 277 display: grid; 149 - gap: 12px; 278 + gap: 6px; 279 + min-width: 0; 280 + padding: 12px 14px 11px; 281 + border-left: 1px solid var(--stats-line-soft); 150 282 } 151 283 152 - .stats-host-card { 153 - display: grid; 154 - gap: 12px; 284 + .stats-band-cell:first-child { 285 + border-left: 0; 155 286 } 156 287 157 - .stats-host-card.q-card-default { 158 - min-height: 0; 288 + .stats-ledger-value-unavailable, 289 + .stats-model-value-unavailable { 290 + color: var(--stats-muted); 291 + font-weight: 500; 159 292 } 160 293 161 - .stats-host-card .q-card-content { 294 + .stats-model-table { 162 295 display: grid; 163 - gap: 12px; 296 + gap: 0; 164 297 } 165 298 166 - .stats-host-metrics { 167 - display: grid; 168 - grid-template-columns: repeat(4, minmax(0, 1fr)); 169 - gap: 12px; 299 + .stats-model-ledger-scroll { 300 + overflow-x: auto; 301 + border-top: 1px solid var(--stats-line-soft); 302 + border-bottom: 1px solid var(--stats-line-soft); 170 303 } 171 304 172 - .stats-host-metric.q-card-tile { 173 - min-height: 0; 305 + .stats-model-ledger-table { 306 + width: max-content; 307 + min-width: 100%; 308 + border-collapse: collapse; 174 309 } 175 310 176 - .stats-host-tile-value { 177 - display: block; 178 - font-family: var(--q-card-title-font-family); 179 - font-variant-numeric: tabular-nums; 180 - font-size: clamp(1rem, 0.95rem + 0.24vw, 1.16rem); 181 - font-weight: 600; 182 - line-height: 1.1; 311 + .stats-model-ledger-group-row th, 312 + .stats-model-ledger-column-row th { 313 + border-right: 1px solid var(--stats-line-soft); 314 + border-bottom: 1px solid var(--stats-line-soft); 315 + background: color-mix(in srgb, var(--bg-2) 95%, var(--accent-1) 5%); 183 316 } 184 317 185 - .stats-metric-label, 186 - .stats-model-label { 187 - display: block; 188 - color: var(--text-2); 189 - font-size: 11px; 190 - letter-spacing: 0.06em; 191 - text-transform: uppercase; 318 + .stats-model-ledger-group-row th:last-child, 319 + .stats-model-ledger-column-row th:last-child, 320 + .stats-model-ledger-table tbody th:last-child, 321 + .stats-model-ledger-table tbody td:last-child { 322 + border-right: 0; 192 323 } 193 324 194 - .stats-metric-value, 195 - .stats-model-value { 196 - display: block; 197 - margin-top: 4px; 198 - font-family: var(--font-ui); 199 - font-variant-numeric: tabular-nums; 200 - font-size: 16px; 325 + .stats-model-ledger-stub, 326 + .stats-model-ledger-group, 327 + .stats-model-ledger-column { 328 + padding: 11px 12px 10px; 329 + white-space: nowrap; 201 330 } 202 331 203 - .stats-model-list { 204 - display: grid; 205 - gap: 0; 206 - margin-top: 0; 207 - border-top: 1px solid var(--line-soft); 332 + .stats-model-ledger-stub, 333 + .stats-model-ledger-group { 334 + text-align: left; 208 335 } 209 336 210 - .stats-empty { 211 - padding: 8px 2px 2px; 212 - color: var(--text-2); 337 + .stats-model-ledger-stub, 338 + .stats-model-ledger-column { 339 + font-family: var(--font-mono); 340 + font-size: 10px; 341 + font-weight: 500; 342 + letter-spacing: 0.12em; 343 + text-transform: uppercase; 344 + color: var(--stats-muted); 213 345 } 214 346 215 - .stats-model-row { 216 - display: grid; 217 - grid-template-columns: 218 - minmax(0, 1.45fr) 219 - minmax(112px, 1.12fr) 220 - minmax(112px, 1.12fr) 221 - minmax(112px, 1.12fr) 222 - minmax(76px, 0.72fr); 223 - gap: 12px; 224 - padding: 10px 0; 225 - border: 0; 226 - border-bottom: 1px solid var(--line-soft); 227 - background: transparent; 347 + .stats-model-ledger-column, 348 + .stats-model-ledger-stub-requests { 349 + text-align: right; 350 + } 351 + 352 + .stats-model-ledger-stub { 353 + min-width: 196px; 354 + vertical-align: bottom; 355 + } 356 + 357 + .stats-model-ledger-stub-requests { 358 + min-width: 88px; 228 359 } 229 360 230 - .stats-model-row > div { 231 - display: grid; 232 - align-content: start; 233 - gap: 4px; 361 + .stats-model-ledger-group { 234 362 min-width: 0; 235 363 } 236 364 237 - .stats-model-row:last-child { 365 + .stats-model-ledger-group-copy { 366 + display: inline-flex; 367 + align-items: center; 368 + gap: 8px; 369 + font-family: var(--font-mono); 370 + font-size: 10px; 371 + font-weight: 600; 372 + letter-spacing: 0.14em; 373 + text-transform: uppercase; 374 + color: var(--stats-ink); 375 + } 376 + 377 + .stats-model-ledger-group-icon { 378 + width: 14px; 379 + min-width: 14px; 380 + height: 14px; 381 + color: var(--stats-accent); 382 + } 383 + 384 + .stats-model-ledger-table tbody th, 385 + .stats-model-ledger-table tbody td { 386 + padding: 13px 12px 12px; 387 + border-right: 1px solid var(--stats-line-soft); 388 + border-bottom: 1px solid var(--stats-line-soft); 389 + vertical-align: top; 390 + } 391 + 392 + .stats-model-ledger-table tbody tr:last-child th, 393 + .stats-model-ledger-table tbody tr:last-child td { 238 394 border-bottom: 0; 239 395 } 240 396 241 - .stats-model-name { 242 - display: block; 243 - font-family: var(--font-ui); 244 - font-weight: 500; 245 - color: var(--text-0); 246 - overflow-wrap: anywhere; 397 + .stats-model-ledger-model { 398 + min-width: 196px; 399 + text-align: left; 400 + background: color-mix(in srgb, var(--bg-1) 95%, var(--accent-1) 5%); 247 401 } 248 402 249 - .stats-model-value { 250 - margin-top: 0; 251 - color: var(--text-1); 403 + .stats-model-ledger-requests { 404 + min-width: 88px; 405 + text-align: right; 252 406 } 253 407 254 - .stats-grid .muted { 255 - font-size: 11px; 408 + .stats-model-ledger-value-cell { 409 + min-width: 126px; 410 + font-family: var(--q-card-title-font-family); 411 + font-size: 1rem; 412 + font-weight: 600; 413 + line-height: 1.05; 414 + color: var(--stats-ink); 415 + font-variant-numeric: tabular-nums; 416 + white-space: nowrap; 417 + text-align: right; 418 + } 419 + 420 + .stats-model-ledger-value-cell-unavailable { 421 + color: var(--stats-muted); 422 + font-weight: 500; 256 423 } 257 424 258 - @media (max-width: 960px) { 259 - .stats-summary-grid { 425 + .stats-empty { 426 + padding-top: 6px; 427 + } 428 + 429 + @media (max-width: 1080px) { 430 + .stats-hero { 260 431 grid-template-columns: 1fr; 261 432 } 433 + } 262 434 263 - .stats-summary-rail { 264 - grid-template-columns: repeat(3, minmax(0, 1fr)); 435 + @media (max-width: 860px) { 436 + .stats-glance-grid { 437 + grid-template-columns: repeat(2, minmax(0, 1fr)); 265 438 } 266 439 267 - .stats-host-metrics { 268 - grid-template-columns: repeat(2, minmax(0, 1fr)); 440 + .stats-request-pill { 441 + justify-items: start; 269 442 } 270 443 } 271 444 272 445 @media (max-width: 720px) { 273 - .stats-summary-head { 446 + .stats-hero-head, 447 + .stats-host-head { 274 448 flex-direction: column; 275 449 align-items: flex-start; 276 450 } 277 451 278 - .stats-model-row { 279 - grid-template-columns: 1fr 1fr; 452 + .stats-hero-meta { 453 + justify-content: flex-start; 280 454 } 281 455 } 282 456 283 - @media (max-width: 620px) { 284 - .stats-summary-grid, 285 - .stats-summary-rail, 286 - .stats-host-metrics { 457 + @media (max-width: 560px) { 458 + .stats-glance-grid, 459 + .stats-band-grid { 287 460 grid-template-columns: 1fr; 461 + } 462 + 463 + .stats-band-cell { 464 + border-left: 0; 465 + border-top: 1px solid var(--stats-line-soft); 466 + } 467 + 468 + .stats-band-cell:first-child { 469 + border-top: 0; 470 + } 471 + 472 + .stats-model-ledger-stub, 473 + .stats-model-ledger-model { 474 + min-width: 164px; 288 475 } 289 476 }
+427 -121
web/console/src/views/StatsView.js
··· 5 5 import AppPage from "../components/AppPage"; 6 6 import { endpointState, formatTime, runtimeApiFetch, translate } from "../core/context"; 7 7 8 + function hasMetricValue(totals, key) { 9 + return Boolean(totals) && Object.prototype.hasOwnProperty.call(totals, key); 10 + } 11 + 8 12 function formatNumber(value) { 9 13 const n = Number(value || 0); 10 14 if (!Number.isFinite(n)) { ··· 13 17 return Math.trunc(n).toLocaleString(); 14 18 } 15 19 16 - function metricItems(t, totals) { 20 + function formatCost(value, currency = "USD") { 21 + const n = Number(value); 22 + if (!Number.isFinite(n)) { 23 + return "-"; 24 + } 25 + try { 26 + return new Intl.NumberFormat(undefined, { 27 + style: "currency", 28 + currency: String(currency || "USD").toUpperCase(), 29 + minimumFractionDigits: Math.abs(n) > 0 && Math.abs(n) < 1 ? 4 : 2, 30 + maximumFractionDigits: 6, 31 + }).format(n); 32 + } catch { 33 + return `${String(currency || "USD").toUpperCase()} ${n.toFixed(4)}`; 34 + } 35 + } 36 + 37 + function formatFixedCost(value, currency = "USD", fractionDigits = 6) { 38 + const n = Number(value); 39 + if (!Number.isFinite(n)) { 40 + return "-"; 41 + } 42 + try { 43 + return new Intl.NumberFormat(undefined, { 44 + style: "currency", 45 + currency: String(currency || "USD").toUpperCase(), 46 + minimumFractionDigits: fractionDigits, 47 + maximumFractionDigits: fractionDigits, 48 + }).format(n); 49 + } catch { 50 + return `${String(currency || "USD").toUpperCase()} ${n.toFixed(fractionDigits)}`; 51 + } 52 + } 53 + 54 + function summaryLeadMetric(t, totals) { 55 + const costCurrency = typeof totals?.cost_currency === "string" ? totals.cost_currency : "USD"; 56 + if (hasMetricValue(totals, "total_cost")) { 57 + return { 58 + key: "total_cost", 59 + label: t("stats_total_cost"), 60 + value: formatCost(totals.total_cost, costCurrency), 61 + }; 62 + } 63 + return { 64 + key: "total_tokens", 65 + label: t("stats_total_tokens"), 66 + value: formatNumber(totals.total_tokens), 67 + }; 68 + } 69 + 70 + function summaryPrimaryMetrics(t, totals) { 71 + const leadKey = summaryLeadMetric(t, totals).key; 72 + return [ 73 + { key: "total_tokens", label: t("stats_total_tokens"), value: formatNumber(totals.total_tokens) }, 74 + { key: "input_tokens", label: t("stats_input_tokens"), value: formatNumber(totals.input_tokens) }, 75 + { key: "output_tokens", label: t("stats_output_tokens"), value: formatNumber(totals.output_tokens) }, 76 + { key: "requests", label: t("stats_requests"), value: formatNumber(totals.requests) }, 77 + { key: "cached_input_tokens", label: t("stats_cached_input_tokens"), value: formatNumber(totals.cached_input_tokens) }, 78 + { 79 + key: "cache_creation_input_tokens", 80 + label: t("stats_cache_creation_input_tokens"), 81 + value: formatNumber(totals.cache_creation_input_tokens), 82 + }, 83 + ].filter((item) => item.key !== leadKey); 84 + } 85 + 86 + function summaryCostMetrics(t, totals) { 87 + const costCurrency = typeof totals?.cost_currency === "string" ? totals.cost_currency : "USD"; 17 88 return [ 18 - { key: "total_tokens", label: t("stats_total_tokens"), value: formatNumber(totals.total_tokens), density: "primary" }, 19 - { key: "input_tokens", label: t("stats_input_tokens"), value: formatNumber(totals.input_tokens), density: "secondary" }, 20 - { key: "output_tokens", label: t("stats_output_tokens"), value: formatNumber(totals.output_tokens), density: "secondary" }, 21 - { key: "requests", label: t("stats_requests"), value: formatNumber(totals.requests), density: "compact" }, 89 + { 90 + key: "input_cost", 91 + label: t("stats_input_cost"), 92 + value: formatCost(totals?.input_cost, costCurrency), 93 + available: hasMetricValue(totals, "input_cost"), 94 + }, 95 + { 96 + key: "output_cost", 97 + label: t("stats_output_cost"), 98 + value: formatCost(totals?.output_cost, costCurrency), 99 + available: hasMetricValue(totals, "output_cost"), 100 + }, 101 + { 102 + key: "cached_input_cost", 103 + label: t("stats_cached_input_cost"), 104 + value: formatCost(totals?.cached_input_cost, costCurrency), 105 + available: hasMetricValue(totals, "cached_input_cost"), 106 + }, 107 + { 108 + key: "cache_creation_input_cost", 109 + label: t("stats_cache_creation_input_cost"), 110 + value: formatCost(totals?.cache_creation_input_cost, costCurrency), 111 + available: hasMetricValue(totals, "cache_creation_input_cost"), 112 + }, 113 + ].filter((item) => item.available); 114 + } 115 + 116 + function costMetrics(t, totals) { 117 + const costCurrency = typeof totals?.cost_currency === "string" ? totals.cost_currency : "USD"; 118 + return [ 119 + { 120 + key: "total_cost", 121 + label: t("stats_total_cost"), 122 + value: hasMetricValue(totals, "total_cost") ? formatCost(totals.total_cost, costCurrency) : "-", 123 + unavailable: !hasMetricValue(totals, "total_cost"), 124 + }, 125 + { 126 + key: "input_cost", 127 + label: t("stats_input_cost"), 128 + value: hasMetricValue(totals, "input_cost") ? formatCost(totals.input_cost, costCurrency) : "-", 129 + unavailable: !hasMetricValue(totals, "input_cost"), 130 + }, 131 + { 132 + key: "output_cost", 133 + label: t("stats_output_cost"), 134 + value: hasMetricValue(totals, "output_cost") ? formatCost(totals.output_cost, costCurrency) : "-", 135 + unavailable: !hasMetricValue(totals, "output_cost"), 136 + }, 137 + { 138 + key: "cached_input_cost", 139 + label: t("stats_cached_input_cost"), 140 + value: hasMetricValue(totals, "cached_input_cost") ? formatCost(totals.cached_input_cost, costCurrency) : "-", 141 + unavailable: !hasMetricValue(totals, "cached_input_cost"), 142 + }, 143 + { 144 + key: "cache_creation_input_cost", 145 + label: t("stats_cache_creation_input_cost"), 146 + value: hasMetricValue(totals, "cache_creation_input_cost") 147 + ? formatCost(totals.cache_creation_input_cost, costCurrency) 148 + : "-", 149 + unavailable: !hasMetricValue(totals, "cache_creation_input_cost"), 150 + }, 151 + ].filter((item) => item.key === "total_cost" || !item.unavailable); 152 + } 153 + 154 + function tokenMetrics(t, totals) { 155 + return [ 156 + { key: "total_tokens", label: t("stats_total_tokens"), value: formatNumber(totals.total_tokens) }, 157 + { key: "input_tokens", label: t("stats_input_tokens"), value: formatNumber(totals.input_tokens) }, 158 + { key: "output_tokens", label: t("stats_output_tokens"), value: formatNumber(totals.output_tokens) }, 159 + { key: "cached_input_tokens", label: t("stats_cached_input_tokens"), value: formatNumber(totals.cached_input_tokens) }, 160 + { 161 + key: "cache_creation_input_tokens", 162 + label: t("stats_cache_creation_input_tokens"), 163 + value: formatNumber(totals.cache_creation_input_tokens), 164 + }, 22 165 ]; 23 166 } 24 167 25 - function sumModelTotals(models) { 26 - const totals = { requests: 0, total_tokens: 0, input_tokens: 0, output_tokens: 0 }; 27 - for (const item of Array.isArray(models) ? models : []) { 28 - totals.requests += Number(item.requests || 0); 29 - totals.total_tokens += Number(item.total_tokens || 0); 30 - totals.input_tokens += Number(item.input_tokens || 0); 31 - totals.output_tokens += Number(item.output_tokens || 0); 168 + function visibleModelCostColumns(t, rows) { 169 + const columns = [ 170 + { key: "total_cost", label: t("stats_total_cost"), kind: "cost" }, 171 + { key: "input_cost", label: t("stats_input_cost"), kind: "cost" }, 172 + { key: "output_cost", label: t("stats_output_cost"), kind: "cost" }, 173 + { key: "cached_input_cost", label: t("stats_cached_input_cost"), kind: "cost" }, 174 + { key: "cache_creation_input_cost", label: t("stats_cache_creation_input_cost"), kind: "cost" }, 175 + ]; 176 + if (!rows.some((row) => columns.some((column) => hasMetricValue(row, column.key)))) { 177 + return []; 178 + } 179 + return columns.filter((column) => rows.some((row) => hasMetricValue(row, column.key))); 180 + } 181 + 182 + function visibleModelTokenColumns(t, rows) { 183 + const columns = [ 184 + { key: "total_tokens", label: t("stats_total_tokens"), kind: "token", always: true }, 185 + { key: "input_tokens", label: t("stats_input_tokens"), kind: "token", always: true }, 186 + { key: "output_tokens", label: t("stats_output_tokens"), kind: "token", always: true }, 187 + { key: "cached_input_tokens", label: t("stats_cached_input_tokens"), kind: "token" }, 188 + { key: "cache_creation_input_tokens", label: t("stats_cache_creation_input_tokens"), kind: "token" }, 189 + ]; 190 + return columns.filter( 191 + (column) => column.always || rows.some((row) => hasMetricValue(row, column.key) || Number(row?.[column.key] || 0) > 0) 192 + ); 193 + } 194 + 195 + function formatModelLedgerValue(row, column) { 196 + if (column.kind === "cost") { 197 + const currency = typeof row?.cost_currency === "string" ? row.cost_currency : "USD"; 198 + return hasMetricValue(row, column.key) ? formatFixedCost(row[column.key], currency) : "-"; 32 199 } 33 - return totals; 200 + return formatNumber(row?.[column.key]); 201 + } 202 + 203 + function isModelLedgerValueUnavailable(row, column) { 204 + return column.kind === "cost" && !hasMetricValue(row, column.key); 34 205 } 35 206 36 207 const StatsView = { ··· 51 222 api_hosts: [], 52 223 models: [], 53 224 }); 225 + 54 226 const statsTabs = computed(() => [ 55 227 { id: "api_hosts", title: t("stats_group_api_hosts") }, 56 228 { id: "models", title: t("stats_group_models") }, 57 229 ]); 58 230 const selectedStatsTab = computed(() => statsTabs.value.find((item) => item.id === activeTabID.value) || statsTabs.value[0] || null); 59 231 60 - const visibleHosts = computed(() => { 61 - return Array.isArray(payload.value.api_hosts) ? payload.value.api_hosts : []; 232 + const visibleHosts = computed(() => (Array.isArray(payload.value.api_hosts) ? payload.value.api_hosts : [])); 233 + const visibleModels = computed(() => (Array.isArray(payload.value.models) ? payload.value.models : [])); 234 + const primarySummaryMetric = computed(() => summaryLeadMetric(t, payload.value.summary || {})); 235 + const secondarySummaryMetrics = computed(() => summaryPrimaryMetrics(t, payload.value.summary || {})); 236 + const secondarySummaryCosts = computed(() => summaryCostMetrics(t, payload.value.summary || {})); 237 + const summaryMetaItems = computed(() => { 238 + const items = []; 239 + if (payload.value.updated_at) { 240 + items.push(`${t("stats_updated_at")}: ${formatTime(payload.value.updated_at)}`); 241 + } 242 + items.push(`${t("stats_projected_records")}: ${formatNumber(payload.value.projected_records)}`); 243 + if (Number(payload.value.skipped_records || 0) > 0) { 244 + items.push(`${t("stats_skipped_records")}: ${formatNumber(payload.value.skipped_records)}`); 245 + } 246 + return items; 62 247 }); 63 - 64 - const visibleModels = computed(() => { 65 - return Array.isArray(payload.value.models) ? payload.value.models : []; 66 - }); 67 - 68 - const summaryMetrics = computed(() => metricItems(t, payload.value.summary || {})); 69 - const primarySummaryMetric = computed(() => summaryMetrics.value[0] || null); 70 - const secondarySummaryMetrics = computed(() => summaryMetrics.value.slice(1)); 71 - 72 248 async function load() { 73 249 loading.value = true; 74 250 err.value = ""; ··· 89 265 } 90 266 } 91 267 92 - function sectionMetrics(item) { 93 - return metricItems(t, item || {}); 268 + function hostCostMetrics(item) { 269 + return costMetrics(t, item || {}); 270 + } 271 + 272 + function hostTokenMetrics(item) { 273 + return tokenMetrics(t, item || {}); 274 + } 275 + 276 + function modelLedgerCostColumns(items) { 277 + return visibleModelCostColumns(t, Array.isArray(items) ? items : []); 278 + } 279 + 280 + function modelLedgerTokenColumns(items) { 281 + return visibleModelTokenColumns(t, Array.isArray(items) ? items : []); 94 282 } 95 283 96 284 function onTabChange(detail) { ··· 115 303 selectedStatsTab, 116 304 visibleHosts, 117 305 visibleModels, 118 - summaryMetrics, 119 306 primarySummaryMetric, 120 307 secondarySummaryMetrics, 121 - load, 308 + secondarySummaryCosts, 309 + summaryMetaItems, 122 310 onTabChange, 123 - sectionMetrics, 124 - formatTime, 311 + hostCostMetrics, 312 + hostTokenMetrics, 313 + modelLedgerCostColumns, 314 + modelLedgerTokenColumns, 315 + formatModelLedgerValue, 316 + isModelLedgerValueUnavailable, 125 317 formatNumber, 126 318 }; 127 319 }, ··· 130 322 <QProgress v-if="loading" :infinite="true" /> 131 323 <QFence v-if="err" type="danger" icon="QIconCloseCircle" :text="err" /> 132 324 133 - <div class="stats-grid"> 134 - <QCard class="stats-summary-board" variant="default"> 135 - <template #header> 136 - <div class="stats-summary-head"> 137 - <AppKicker as="h3" left="LLM" right="Summary" /> 138 - <p class="stats-summary-meta">{{ t("stats_updated_at") }}: {{ formatTime(payload.updated_at) }}</p> 325 + <section class="stats-page"> 326 + <header class="stats-hero"> 327 + <div class="stats-hero-copy"> 328 + <div class="stats-hero-head"> 329 + <AppKicker as="h3" left="LLM" right="Usage" /> 330 + <p v-if="summaryMetaItems.length > 0" class="stats-hero-meta"> 331 + <span v-for="item in summaryMetaItems" :key="item" class="stats-hero-meta-item">{{ item }}</span> 332 + </p> 139 333 </div> 140 - </template> 141 - <div class="stats-summary-grid" v-if="primarySummaryMetric"> 142 - <article class="stats-summary-lead"> 143 - <span class="stats-summary-label">{{ primarySummaryMetric.label }}</span> 144 - <span class="stats-summary-value">{{ primarySummaryMetric.value }}</span> 145 - </article> 146 - <div class="stats-summary-rail"> 147 - <QCard 148 - v-for="item in secondarySummaryMetrics" 149 - :key="item.key" 150 - class="stats-summary-rail-item" 151 - variant="tile" 152 - > 153 - <div class="stats-summary-tile-copy"> 154 - <span class="stats-summary-tile-label">{{ item.label }}</span> 155 - <span class="stats-summary-tile-value">{{ item.value }}</span> 156 - </div> 157 - </QCard> 334 + <div class="stats-lead-block"> 335 + <span class="stats-lead-label">{{ primarySummaryMetric.label }}</span> 336 + <span class="stats-lead-value">{{ primarySummaryMetric.value }}</span> 337 + </div> 338 + <div v-if="secondarySummaryCosts.length > 0" class="stats-inline-meta stats-inline-meta-summary"> 339 + <div v-for="item in secondarySummaryCosts" :key="item.key" class="stats-inline-meta-item"> 340 + <span class="stats-inline-meta-label">{{ item.label }}</span> 341 + <span class="stats-inline-meta-value">{{ item.value }}</span> 342 + </div> 343 + </div> 344 + </div> 345 + <div class="stats-glance-grid"> 346 + <div v-for="item in secondarySummaryMetrics" :key="item.key" class="stats-glance-item"> 347 + <span class="stats-glance-label">{{ item.label }}</span> 348 + <span class="stats-glance-value">{{ item.value }}</span> 158 349 </div> 159 350 </div> 160 - </QCard> 351 + </header> 161 352 162 353 <section class="stats-section"> 163 354 <QTabs ··· 171 362 <div v-if="selectedStatsTab && selectedStatsTab.id === 'api_hosts'" class="stats-section-panel"> 172 363 <div v-if="visibleHosts.length === 0" class="stats-empty">{{ t("stats_no_data") }}</div> 173 364 <div v-else class="stats-host-list"> 174 - <QCard 175 - v-for="host in visibleHosts" 176 - :key="host.api_host" 177 - class="stats-host-card" 178 - variant="default" 179 - :eyebrow="host.api_host" 180 - > 181 - <div class="stats-host-metrics"> 182 - <QCard 183 - v-for="item in sectionMetrics(host)" 184 - :key="host.api_host + ':' + item.key" 185 - class="stats-host-metric" 186 - variant="tile" 187 - > 188 - <div class="stats-host-tile-copy"> 189 - <span class="stats-host-tile-label">{{ item.label }}</span> 190 - <span class="stats-host-tile-value">{{ item.value }}</span> 365 + <article v-for="host in visibleHosts" :key="host.api_host" class="stats-host-block"> 366 + <header class="stats-host-head"> 367 + <div class="stats-host-ident"> 368 + <span class="stats-host-eyebrow">{{ t("stats_api_host") }}</span> 369 + <code class="stats-host-name">{{ host.api_host }}</code> 370 + </div> 371 + <div class="stats-request-pill"> 372 + <span class="stats-request-pill-label">{{ t("stats_requests") }}</span> 373 + <span class="stats-request-pill-value">{{ formatNumber(host.requests) }}</span> 374 + </div> 375 + </header> 376 + 377 + <section class="stats-band stats-band-cost"> 378 + <header class="stats-band-head"> 379 + <QIconWallet class="stats-band-icon icon" /> 380 + <span class="stats-band-title">{{ t("stats_costs") }}</span> 381 + </header> 382 + <div class="stats-band-grid"> 383 + <div v-for="item in hostCostMetrics(host)" :key="host.api_host + ':cost:' + item.key" class="stats-band-cell"> 384 + <span class="stats-ledger-label">{{ item.label }}</span> 385 + <span class="stats-ledger-value" :class="{ 'stats-ledger-value-unavailable': item.unavailable }">{{ item.value }}</span> 191 386 </div> 192 - </QCard> 193 - </div> 194 - <div v-if="Array.isArray(host.models) && host.models.length > 0" class="stats-model-list"> 195 - <div v-for="model in host.models" :key="host.api_host + ':' + model.model" class="stats-model-row"> 196 - <div> 197 - <span class="stats-model-label">{{ t("stats_model") }}</span> 198 - <span class="stats-model-name">{{ model.model }}</span> 199 - </div> 200 - <div> 201 - <span class="stats-model-label">{{ t("stats_total_tokens") }}</span> 202 - <span class="stats-model-value">{{ formatNumber(model.total_tokens) }}</span> 203 - </div> 204 - <div> 205 - <span class="stats-model-label">{{ t("stats_input_tokens") }}</span> 206 - <span class="stats-model-value">{{ formatNumber(model.input_tokens) }}</span> 207 - </div> 208 - <div> 209 - <span class="stats-model-label">{{ t("stats_output_tokens") }}</span> 210 - <span class="stats-model-value">{{ formatNumber(model.output_tokens) }}</span> 211 - </div> 212 - <div> 213 - <span class="stats-model-label">{{ t("stats_requests") }}</span> 214 - <span class="stats-model-value">{{ formatNumber(model.requests) }}</span> 387 + </div> 388 + </section> 389 + 390 + <section class="stats-band stats-band-token"> 391 + <header class="stats-band-head"> 392 + <QIconBarChart class="stats-band-icon icon" /> 393 + <span class="stats-band-title">{{ t("stats_tokens") }}</span> 394 + </header> 395 + <div class="stats-band-grid"> 396 + <div v-for="item in hostTokenMetrics(host)" :key="host.api_host + ':token:' + item.key" class="stats-band-cell"> 397 + <span class="stats-ledger-label">{{ item.label }}</span> 398 + <span class="stats-ledger-value">{{ item.value }}</span> 215 399 </div> 216 400 </div> 401 + </section> 402 + 403 + <div v-if="Array.isArray(host.models) && host.models.length > 0" class="stats-model-table"> 404 + <div class="stats-model-ledger-scroll"> 405 + <table class="stats-model-ledger-table"> 406 + <thead> 407 + <tr class="stats-model-ledger-group-row"> 408 + <th rowspan="2" class="stats-model-ledger-stub">{{ t("stats_model") }}</th> 409 + <th rowspan="2" class="stats-model-ledger-stub stats-model-ledger-stub-requests">{{ t("stats_requests") }}</th> 410 + <th 411 + v-if="modelLedgerCostColumns(host.models).length > 0" 412 + :colspan="modelLedgerCostColumns(host.models).length" 413 + class="stats-model-ledger-group" 414 + > 415 + <span class="stats-model-ledger-group-copy"> 416 + <QIconWallet class="stats-model-ledger-group-icon icon" /> 417 + <span>{{ t("stats_costs") }}</span> 418 + </span> 419 + </th> 420 + <th :colspan="modelLedgerTokenColumns(host.models).length" class="stats-model-ledger-group"> 421 + <span class="stats-model-ledger-group-copy"> 422 + <QIconBarChart class="stats-model-ledger-group-icon icon" /> 423 + <span>{{ t("stats_tokens") }}</span> 424 + </span> 425 + </th> 426 + </tr> 427 + <tr class="stats-model-ledger-column-row"> 428 + <th 429 + v-for="column in modelLedgerCostColumns(host.models)" 430 + :key="host.api_host + ':head:cost:' + column.key" 431 + class="stats-model-ledger-column" 432 + > 433 + {{ column.label }} 434 + </th> 435 + <th 436 + v-for="column in modelLedgerTokenColumns(host.models)" 437 + :key="host.api_host + ':head:token:' + column.key" 438 + class="stats-model-ledger-column" 439 + > 440 + {{ column.label }} 441 + </th> 442 + </tr> 443 + </thead> 444 + <tbody> 445 + <tr v-for="model in host.models" :key="host.api_host + ':' + model.model" class="stats-model-ledger-row"> 446 + <th scope="row" class="stats-model-ledger-model"> 447 + <code class="stats-model-name">{{ model.model }}</code> 448 + </th> 449 + <td class="stats-model-ledger-value-cell stats-model-ledger-requests">{{ formatNumber(model.requests) }}</td> 450 + <td 451 + v-for="column in modelLedgerCostColumns(host.models)" 452 + :key="host.api_host + ':' + model.model + ':cost:' + column.key" 453 + class="stats-model-ledger-value-cell" 454 + :class="{ 'stats-model-ledger-value-cell-unavailable': isModelLedgerValueUnavailable(model, column) }" 455 + > 456 + {{ formatModelLedgerValue(model, column) }} 457 + </td> 458 + <td 459 + v-for="column in modelLedgerTokenColumns(host.models)" 460 + :key="host.api_host + ':' + model.model + ':token:' + column.key" 461 + class="stats-model-ledger-value-cell" 462 + > 463 + {{ formatModelLedgerValue(model, column) }} 464 + </td> 465 + </tr> 466 + </tbody> 467 + </table> 468 + </div> 217 469 </div> 218 - </QCard> 470 + </article> 219 471 </div> 220 472 </div> 221 473 222 - <QCard v-else class="stats-section-panel stats-section-panel-models" variant="default"> 474 + <div v-else class="stats-section-panel"> 223 475 <div v-if="visibleModels.length === 0" class="stats-empty">{{ t("stats_no_data") }}</div> 224 - <div v-else class="stats-model-list"> 225 - <div v-for="model in visibleModels" :key="model.model" class="stats-model-row"> 226 - <div> 227 - <span class="stats-model-label">{{ t("stats_model") }}</span> 228 - <span class="stats-model-name">{{ model.model }}</span> 229 - </div> 230 - <div> 231 - <span class="stats-model-label">{{ t("stats_total_tokens") }}</span> 232 - <span class="stats-model-value">{{ formatNumber(model.total_tokens) }}</span> 233 - </div> 234 - <div> 235 - <span class="stats-model-label">{{ t("stats_input_tokens") }}</span> 236 - <span class="stats-model-value">{{ formatNumber(model.input_tokens) }}</span> 237 - </div> 238 - <div> 239 - <span class="stats-model-label">{{ t("stats_output_tokens") }}</span> 240 - <span class="stats-model-value">{{ formatNumber(model.output_tokens) }}</span> 241 - </div> 242 - <div> 243 - <span class="stats-model-label">{{ t("stats_requests") }}</span> 244 - <span class="stats-model-value">{{ formatNumber(model.requests) }}</span> 476 + <div v-else class="stats-host-list"> 477 + <section class="stats-host-block"> 478 + <header class="stats-host-head"> 479 + <div class="stats-host-ident"> 480 + <span class="stats-host-eyebrow">{{ t("stats_group_models") }}</span> 481 + <span class="stats-host-name">{{ t("stats_model") }}</span> 482 + </div> 483 + </header> 484 + 485 + <div class="stats-model-table"> 486 + <div class="stats-model-ledger-scroll"> 487 + <table class="stats-model-ledger-table"> 488 + <thead> 489 + <tr class="stats-model-ledger-group-row"> 490 + <th rowspan="2" class="stats-model-ledger-stub">{{ t("stats_model") }}</th> 491 + <th rowspan="2" class="stats-model-ledger-stub stats-model-ledger-stub-requests">{{ t("stats_requests") }}</th> 492 + <th 493 + v-if="modelLedgerCostColumns(visibleModels).length > 0" 494 + :colspan="modelLedgerCostColumns(visibleModels).length" 495 + class="stats-model-ledger-group" 496 + > 497 + <span class="stats-model-ledger-group-copy"> 498 + <QIconWallet class="stats-model-ledger-group-icon icon" /> 499 + <span>{{ t("stats_costs") }}</span> 500 + </span> 501 + </th> 502 + <th :colspan="modelLedgerTokenColumns(visibleModels).length" class="stats-model-ledger-group"> 503 + <span class="stats-model-ledger-group-copy"> 504 + <QIconBarChart class="stats-model-ledger-group-icon icon" /> 505 + <span>{{ t("stats_tokens") }}</span> 506 + </span> 507 + </th> 508 + </tr> 509 + <tr class="stats-model-ledger-column-row"> 510 + <th 511 + v-for="column in modelLedgerCostColumns(visibleModels)" 512 + :key="'models:head:cost:' + column.key" 513 + class="stats-model-ledger-column" 514 + > 515 + {{ column.label }} 516 + </th> 517 + <th 518 + v-for="column in modelLedgerTokenColumns(visibleModels)" 519 + :key="'models:head:token:' + column.key" 520 + class="stats-model-ledger-column" 521 + > 522 + {{ column.label }} 523 + </th> 524 + </tr> 525 + </thead> 526 + <tbody> 527 + <tr v-for="model in visibleModels" :key="model.model" class="stats-model-ledger-row"> 528 + <th scope="row" class="stats-model-ledger-model"> 529 + <code class="stats-model-name">{{ model.model }}</code> 530 + </th> 531 + <td class="stats-model-ledger-value-cell stats-model-ledger-requests">{{ formatNumber(model.requests) }}</td> 532 + <td 533 + v-for="column in modelLedgerCostColumns(visibleModels)" 534 + :key="model.model + ':cost:' + column.key" 535 + class="stats-model-ledger-value-cell" 536 + :class="{ 'stats-model-ledger-value-cell-unavailable': isModelLedgerValueUnavailable(model, column) }" 537 + > 538 + {{ formatModelLedgerValue(model, column) }} 539 + </td> 540 + <td 541 + v-for="column in modelLedgerTokenColumns(visibleModels)" 542 + :key="model.model + ':token:' + column.key" 543 + class="stats-model-ledger-value-cell" 544 + > 545 + {{ formatModelLedgerValue(model, column) }} 546 + </td> 547 + </tr> 548 + </tbody> 549 + </table> 550 + </div> 245 551 </div> 246 - </div> 552 + </section> 247 553 </div> 248 - </QCard> 554 + </div> 249 555 </section> 250 - </div> 556 + </section> 251 557 </AppPage> 252 558 `, 253 559 };