|
| 1 | +/* |
| 2 | + * Licensed to the Apache Software Foundation (ASF) under one |
| 3 | + * or more contributor license agreements. See the NOTICE file |
| 4 | + * distributed with this work for additional information |
| 5 | + * regarding copyright ownership. The ASF licenses this file |
| 6 | + * to you under the Apache License, Version 2.0 (the |
| 7 | + * "License"); you may not use this file except in compliance |
| 8 | + * with the License. You may obtain a copy of the License at |
| 9 | + * |
| 10 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 11 | + * |
| 12 | + * Unless required by applicable law or agreed to in writing, |
| 13 | + * software distributed under the License is distributed on an |
| 14 | + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| 15 | + * KIND, either express or implied. See the License for the |
| 16 | + * specific language governing permissions and limitations |
| 17 | + * under the License. |
| 18 | + */ |
| 19 | + |
| 20 | +import assert from 'node:assert/strict'; |
| 21 | +import { test } from 'node:test'; |
| 22 | +import { |
| 23 | + deriveInspectorOverviewModel, |
| 24 | + RING_ACTIVE_MIN_SWEEP, |
| 25 | + RING_MIN_SWEEP, |
| 26 | + usageRingArcs, |
| 27 | +} from '../../renderer/features/workbar/testing.js'; |
| 28 | +import type { UsageSummaryV2 } from '@maka/core/usage-stats/types'; |
| 29 | + |
| 30 | +function usageSummary(overrides: Partial<UsageSummaryV2> = {}): UsageSummaryV2 { |
| 31 | + return { |
| 32 | + range: { from: 0, to: 1 }, |
| 33 | + totalRequests: 3, |
| 34 | + totalCostUsd: 0.02, |
| 35 | + totalTokens: { |
| 36 | + input: 4_000_000, |
| 37 | + output: 60_300, |
| 38 | + cacheMiss: 100_000, |
| 39 | + cacheRead: 3_900_000, |
| 40 | + cacheWrite: 0, |
| 41 | + reasoning: 12_000, |
| 42 | + total: 4_060_300, |
| 43 | + }, |
| 44 | + cacheHitRequests: 2, |
| 45 | + cacheCreateRequests: 0, |
| 46 | + errorRequests: 0, |
| 47 | + ...overrides, |
| 48 | + }; |
| 49 | +} |
| 50 | + |
| 51 | +test('splits the session metered tokens the way a bill reads', () => { |
| 52 | + const { tokenUsage } = deriveInspectorOverviewModel( |
| 53 | + undefined, |
| 54 | + usageSummary({ totalDurationMs: 1_885_000 }), |
| 55 | + ); |
| 56 | + |
| 57 | + assert.ok(tokenUsage); |
| 58 | + assert.deepEqual( |
| 59 | + tokenUsage.segments.map((segment) => [segment.kind, segment.tokens]), |
| 60 | + [ |
| 61 | + ['cacheRead', 3_900_000], |
| 62 | + ['cacheMiss', 100_000], |
| 63 | + ['output', 60_300], |
| 64 | + ], |
| 65 | + ); |
| 66 | + // The readout is the sum of the drawn rows, so the legend and its total |
| 67 | + // cannot disagree even when the ledger's own parts drifted. |
| 68 | + assert.equal( |
| 69 | + tokenUsage.total, |
| 70 | + tokenUsage.segments.reduce((carry, segment) => carry + segment.tokens, 0), |
| 71 | + ); |
| 72 | +}); |
| 73 | + |
| 74 | +test('derives uncached input as the prompt residual when a provider reports only its cache', () => { |
| 75 | + // A cache-reading-only provider leaves the ledger's cacheMiss at zero; the |
| 76 | + // row must still carry what the session paid as uncached input. |
| 77 | + const { tokenUsage } = deriveInspectorOverviewModel( |
| 78 | + undefined, |
| 79 | + usageSummary({ |
| 80 | + totalTokens: { |
| 81 | + input: 260_500, |
| 82 | + output: 60_300, |
| 83 | + cacheMiss: 0, |
| 84 | + cacheRead: 200_000, |
| 85 | + cacheWrite: 0, |
| 86 | + reasoning: 0, |
| 87 | + total: 320_800, |
| 88 | + }, |
| 89 | + }), |
| 90 | + ); |
| 91 | + |
| 92 | + assert.deepEqual( |
| 93 | + tokenUsage?.segments.map((segment) => [segment.kind, segment.tokens]), |
| 94 | + [ |
| 95 | + ['cacheRead', 200_000], |
| 96 | + ['cacheMiss', 60_500], |
| 97 | + ['output', 60_300], |
| 98 | + ], |
| 99 | + ); |
| 100 | +}); |
| 101 | + |
| 102 | +test('keeps the ledger cacheMiss as a floor for records that reported no prompt total', () => { |
| 103 | + const { tokenUsage } = deriveInspectorOverviewModel( |
| 104 | + undefined, |
| 105 | + usageSummary({ |
| 106 | + totalTokens: { |
| 107 | + input: 0, |
| 108 | + output: 100, |
| 109 | + cacheMiss: 500, |
| 110 | + cacheRead: 0, |
| 111 | + cacheWrite: 0, |
| 112 | + reasoning: 0, |
| 113 | + total: 100, |
| 114 | + }, |
| 115 | + }), |
| 116 | + ); |
| 117 | + |
| 118 | + assert.deepEqual( |
| 119 | + tokenUsage?.segments.map((segment) => [segment.kind, segment.tokens]), |
| 120 | + [ |
| 121 | + ['cacheMiss', 500], |
| 122 | + ['output', 100], |
| 123 | + ], |
| 124 | + ); |
| 125 | +}); |
| 126 | + |
| 127 | +test('a session with nothing metered has no token split to show', () => { |
| 128 | + const { tokenUsage } = deriveInspectorOverviewModel( |
| 129 | + undefined, |
| 130 | + usageSummary({ |
| 131 | + totalTokens: { |
| 132 | + input: 0, |
| 133 | + output: 0, |
| 134 | + cacheMiss: 0, |
| 135 | + cacheRead: 0, |
| 136 | + cacheWrite: 0, |
| 137 | + reasoning: 0, |
| 138 | + total: 0, |
| 139 | + }, |
| 140 | + }), |
| 141 | + ); |
| 142 | + assert.equal(tokenUsage, undefined); |
| 143 | + assert.equal(deriveInspectorOverviewModel(undefined, undefined).tokenUsage, undefined); |
| 144 | +}); |
| 145 | + |
| 146 | +test('shows the token split even when usage coverage is partial, since the rows are what ran', () => { |
| 147 | + // The cache-hit RATE goes unavailable under partial usage — a rate over a |
| 148 | + // part is a lie. The split stays: its rows state what was recorded, which |
| 149 | + // only undercounts, never fabricates. |
| 150 | + const { tokenUsage, cacheHitRate } = deriveInspectorOverviewModel(undefined, { |
| 151 | + ...usageSummary(), |
| 152 | + provenance: { |
| 153 | + coverage: { |
| 154 | + attempts: 3, |
| 155 | + pricedAttempts: 3, |
| 156 | + unpricedAttempts: 0, |
| 157 | + usageReportedAttempts: 2, |
| 158 | + usagePartialAttempts: 1, |
| 159 | + usageMissingAttempts: 0, |
| 160 | + }, |
| 161 | + legacyRecords: 0, |
| 162 | + unreadableRecords: 0, |
| 163 | + pendingRepairs: 0, |
| 164 | + }, |
| 165 | + }); |
| 166 | + assert.ok(tokenUsage); |
| 167 | + assert.equal(cacheHitRate, undefined); |
| 168 | +}); |
| 169 | + |
| 170 | +test('splits recorded time between model calls and tool executions', () => { |
| 171 | + const { durationUsage } = deriveInspectorOverviewModel( |
| 172 | + undefined, |
| 173 | + usageSummary({ totalDurationMs: 1_873_000, toolUsage: { requests: 78, durationMs: 78_000 } }), |
| 174 | + ); |
| 175 | + |
| 176 | + assert.deepEqual( |
| 177 | + durationUsage?.segments.map((segment) => [segment.kind, segment.count, segment.durationMs]), |
| 178 | + [ |
| 179 | + ['model', 3, 1_873_000], |
| 180 | + ['tool', 78, 78_000], |
| 181 | + ], |
| 182 | + ); |
| 183 | + assert.equal(durationUsage?.totalDurationMs, 1_873_000 + 78_000); |
| 184 | +}); |
| 185 | + |
| 186 | +test('a host from before duration reporting shows no time split rather than zeros', () => { |
| 187 | + const { durationUsage } = deriveInspectorOverviewModel(undefined, usageSummary()); |
| 188 | + assert.equal(durationUsage, undefined); |
| 189 | +}); |
| 190 | + |
| 191 | +test('a tool row without a recorded duration still reports its count', () => { |
| 192 | + const { durationUsage } = deriveInspectorOverviewModel( |
| 193 | + undefined, |
| 194 | + usageSummary({ totalRequests: 2, toolUsage: { requests: 4, durationMs: 0 } }), |
| 195 | + ); |
| 196 | + |
| 197 | + assert.deepEqual( |
| 198 | + durationUsage?.segments.map((segment) => [segment.kind, segment.count, segment.durationMs]), |
| 199 | + [['tool', 4, 0]], |
| 200 | + ); |
| 201 | + assert.equal(durationUsage?.totalDurationMs, 0); |
| 202 | +}); |
| 203 | + |
| 204 | +test('model time without tool usage reads as a single-segment split', () => { |
| 205 | + const { durationUsage } = deriveInspectorOverviewModel( |
| 206 | + undefined, |
| 207 | + usageSummary({ totalRequests: 5, totalDurationMs: 2_500 }), |
| 208 | + ); |
| 209 | + |
| 210 | + assert.deepEqual( |
| 211 | + durationUsage?.segments.map((segment) => [segment.kind, segment.count, segment.durationMs]), |
| 212 | + [['model', 5, 2_500]], |
| 213 | + ); |
| 214 | +}); |
| 215 | + |
| 216 | +test('ring arcs keep reading order and clamp the last segment to the full turn', () => { |
| 217 | + const arcs = usageRingArcs( |
| 218 | + [ |
| 219 | + { kind: 'cacheRead' as const, amount: 1 }, |
| 220 | + { kind: 'cacheMiss' as const, amount: 1 }, |
| 221 | + { kind: 'output' as const, amount: 1 }, |
| 222 | + ], |
| 223 | + 3, |
| 224 | + ); |
| 225 | + assert.deepEqual( |
| 226 | + arcs.map((arc) => arc.kind), |
| 227 | + ['cacheRead', 'cacheMiss', 'output'], |
| 228 | + ); |
| 229 | + assert.equal(arcs[0]?.start, 0); |
| 230 | + // The last arc is clamped to the full turn rather than accumulated, so a |
| 231 | + // rounded share can never leave an unexplained sliver at the seam. |
| 232 | + assert.equal(arcs.at(-1)?.end, 1); |
| 233 | + for (const arc of arcs) { |
| 234 | + assert.ok(arc.end > arc.start); |
| 235 | + assert.match(arc.d, /^M [\d. ]+ A/); |
| 236 | + assert.match(arc.d, / Z$/); |
| 237 | + } |
| 238 | +}); |
| 239 | + |
| 240 | +test('a segment owning the whole ring walks two half-turns instead of one arc', () => { |
| 241 | + const arcs = usageRingArcs([{ kind: 'model' as const, amount: 2_500 }], 2_500); |
| 242 | + assert.equal(arcs.length, 1); |
| 243 | + assert.equal(arcs[0]?.start, 0); |
| 244 | + assert.equal(arcs[0]?.end, 1); |
| 245 | + // One arc cannot sweep 360°; two half arcs render the full donut. |
| 246 | + assert.equal(arcs[0]?.d.match(/ A /g)?.length, 4); |
| 247 | +}); |
| 248 | + |
| 249 | +test('a ring with nothing measured draws no arcs and leaves the muted track', () => { |
| 250 | + assert.equal(usageRingArcs([{ kind: 'tool' as const, amount: 0 }], 0).length, 0); |
| 251 | + assert.equal(usageRingArcs([], 100).length, 0); |
| 252 | +}); |
| 253 | + |
| 254 | +test('a nonzero sliver keeps a visible sweep even when its share rounds to zero', () => { |
| 255 | + const arcs = usageRingArcs( |
| 256 | + [ |
| 257 | + { kind: 'model' as const, amount: 77_000 }, |
| 258 | + { kind: 'tool' as const, amount: 35 }, |
| 259 | + ], |
| 260 | + 77_035, |
| 261 | + ); |
| 262 | + const tool = arcs[1]; |
| 263 | + // The end-of-turn clamp re-derives the last sweep from the running cursor, |
| 264 | + // so compare with the usual floating-point courtesy. |
| 265 | + assert.ok(tool.end - tool.start >= RING_MIN_SWEEP - 1e-9); |
| 266 | + assert.equal(tool.end, 1); |
| 267 | +}); |
| 268 | + |
| 269 | +test('hovering a tiny segment expands it on the ring so the highlight lands somewhere', () => { |
| 270 | + const arcs = usageRingArcs( |
| 271 | + [ |
| 272 | + { kind: 'model' as const, amount: 77_000 }, |
| 273 | + { kind: 'tool' as const, amount: 35 }, |
| 274 | + ], |
| 275 | + 77_035, |
| 276 | + 'tool', |
| 277 | + ); |
| 278 | + const tool = arcs[1]; |
| 279 | + // The end-of-turn clamp re-derives the last sweep from the running cursor, |
| 280 | + // so compare with the usual floating-point courtesy. |
| 281 | + assert.ok(tool.end - tool.start >= RING_ACTIVE_MIN_SWEEP - 1e-9); |
| 282 | + // The cost of the focus floor comes out of the dominant share, and the |
| 283 | + // seam still closes on the full turn. |
| 284 | + assert.ok(arcs[0].end <= 1 - RING_ACTIVE_MIN_SWEEP + 1e-9); |
| 285 | + assert.equal(tool.end, 1); |
| 286 | +}); |
| 287 | + |
| 288 | +test('hovering the dominant segment leaves the layout untouched', () => { |
| 289 | + const arcs = usageRingArcs( |
| 290 | + [ |
| 291 | + { kind: 'model' as const, amount: 77_000 }, |
| 292 | + { kind: 'tool' as const, amount: 35 }, |
| 293 | + ], |
| 294 | + 77_035, |
| 295 | + 'model', |
| 296 | + ); |
| 297 | + assert.ok(arcs[0].end >= 1 - RING_MIN_SWEEP); |
| 298 | + assert.equal(arcs[1].end, 1); |
| 299 | +}); |
0 commit comments