dsh-token-total 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/lib/client.js +273 -147
- package/lib/index.js +159 -43
- package/lib/types/client/session-usage-core.d.ts +10 -0
- package/lib/types/client/use-session-usage.d.ts +2 -18
- package/lib/types/collector.d.ts +8 -9
- package/lib/types/context-types.d.ts +23 -2
- package/lib/types/model-names.d.ts +6 -0
- package/lib/types/session-usage.d.ts +8 -9
- package/lib/types/wire.d.ts +23 -1
- package/package.json +1 -1
- package/src/client/StatsLineEnhanced.tsx +15 -33
- package/src/client/TokenTotalPanel.module.css +72 -5
- package/src/client/TokenTotalPanel.tsx +199 -53
- package/src/client/session-usage-core.ts +58 -0
- package/src/client/use-session-usage.ts +45 -98
- package/src/collector.ts +57 -51
- package/src/context-types.ts +17 -2
- package/src/model-names.ts +34 -0
- package/src/query.ts +61 -5
- package/src/routes.ts +14 -2
- package/src/session-usage.ts +50 -38
- package/src/wire.ts +17 -1
|
@@ -55,10 +55,8 @@ import {
|
|
|
55
55
|
type ConversationNodeLike, type PartialAssistantLike, type UseProjection, type WindowStats,
|
|
56
56
|
} from './stats-line-core.ts'
|
|
57
57
|
import { formatTokens } from './format.ts'
|
|
58
|
-
import {
|
|
59
|
-
|
|
60
|
-
type SessionTreeReading,
|
|
61
|
-
} from './use-session-usage.ts'
|
|
58
|
+
import { useSessionUsage, type SessionTreeReading } from './use-session-usage.ts'
|
|
59
|
+
import { billedTotal, hasAnyTokens, sessionUsageForDisplay } from './session-usage-core.ts'
|
|
62
60
|
import type { SessionUsageTotals } from '../wire.ts'
|
|
63
61
|
import css from './StatsLineEnhanced.module.css'
|
|
64
62
|
|
|
@@ -363,10 +361,8 @@ function TimePill({ stats, t, dialog, useChat, scale, dock }: {
|
|
|
363
361
|
}
|
|
364
362
|
|
|
365
363
|
function UsagePill({ usage, tree, t, dialog, dock }: {
|
|
366
|
-
usage: Parameters<typeof billedInputTokens>[0]
|
|
367
|
-
/**
|
|
368
|
-
* answered. Subagent spend is invisible to the native projection, so the
|
|
369
|
-
* pill's total and its dialog both switch to this once it carries any. */
|
|
364
|
+
usage: Parameters<typeof billedInputTokens>[0] | undefined
|
|
365
|
+
/** Descendant spend from the host; the main-agent figure stays live. */
|
|
370
366
|
tree?: SessionTreeReading
|
|
371
367
|
t: T
|
|
372
368
|
/** The row's shared exclusive slot; omitted by a split-mode entry. */
|
|
@@ -375,20 +371,10 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
|
|
|
375
371
|
dock: boolean
|
|
376
372
|
}) {
|
|
377
373
|
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const nativeTotals: SessionUsageTotals = {
|
|
383
|
-
uncachedInputTokens: usage.uncachedInputTokens,
|
|
384
|
-
outputTokens: usage.outputTokens,
|
|
385
|
-
cacheReadTokens: usage.cacheReadTokens,
|
|
386
|
-
cacheWriteTokens: usage.cacheWriteTokens,
|
|
387
|
-
}
|
|
388
|
-
const treeTotals = tree?.totals ?? null
|
|
389
|
-
const subagentTotals = tree?.tree?.subagents
|
|
390
|
-
const showSubagents = subagentTotals !== undefined && hasAnyTokens(subagentTotals)
|
|
391
|
-
const billed = showSubagents && treeTotals !== null ? treeTotals : nativeTotals
|
|
374
|
+
const display = sessionUsageForDisplay(usage, tree?.tree ?? null)
|
|
375
|
+
const subagentTotals = display.subagents
|
|
376
|
+
const showSubagents = display.subagentSessions > 0
|
|
377
|
+
const billed = display.total
|
|
392
378
|
// Same aggregate as the official pill total: every prompt-side billing bucket plus output.
|
|
393
379
|
const total = billedTotal(billed)
|
|
394
380
|
const totalText = t('message.turnUsage.count', { count: formatTokensCompact(total) })
|
|
@@ -461,10 +447,10 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
|
|
|
461
447
|
<dd>{exactCount(billed.outputTokens)}</dd>
|
|
462
448
|
{/* The split that the native figure cannot show: this session's own
|
|
463
449
|
main agent against every subagent it delegated to. */}
|
|
464
|
-
{showSubagents &&
|
|
450
|
+
{showSubagents && (
|
|
465
451
|
<>
|
|
466
452
|
<dt>{t('stats.dialog.main')}</dt>
|
|
467
|
-
<dd>{exactCount(billedTotal(
|
|
453
|
+
<dd>{exactCount(billedTotal(display.self))}</dd>
|
|
468
454
|
<dt>{t('stats.dialog.subagents')}</dt>
|
|
469
455
|
<dd>
|
|
470
456
|
{exactCount(billedTotal(subagentTotals))}
|
|
@@ -472,7 +458,7 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
|
|
|
472
458
|
{t('stats.dialog.subagentCount', { count: tree?.tree?.subagentSessions ?? 0 })}
|
|
473
459
|
</dd>
|
|
474
460
|
<dt>{t('stats.dialog.treeTotal')}</dt>
|
|
475
|
-
<dd>{exactCount(billedTotal(
|
|
461
|
+
<dd>{exactCount(billedTotal(billed))}</dd>
|
|
476
462
|
</>
|
|
477
463
|
)}
|
|
478
464
|
</dl>
|
|
@@ -506,13 +492,10 @@ export const StatsLineEnhanced = memo(function StatsLineEnhanced(
|
|
|
506
492
|
// density predicted for the same text. 1 (the raw prior) until a settled step
|
|
507
493
|
// carries both, so the first stream of a session still reads sensibly.
|
|
508
494
|
const tokenScale = useMemo(() => measuredTokenScale(settledNodes ?? []) ?? 1, [settledNodes])
|
|
509
|
-
//
|
|
510
|
-
// usage figure moves, which is exactly when a main-agent call settled.
|
|
495
|
+
// Refresh on main-agent settlements and poll for child-only activity.
|
|
511
496
|
const tree = useSessionUsage(sessionId, usageSignature(usage, stats.steps))
|
|
512
|
-
//
|
|
513
|
-
|
|
514
|
-
const hasTokens = usage !== undefined
|
|
515
|
-
&& (billedInputTokens(usage) > 0 || usage.outputTokens > 0)
|
|
497
|
+
// A delegated-only session must still show its usage pill.
|
|
498
|
+
const hasTokens = hasAnyTokens(sessionUsageForDisplay(usage, tree.tree).total)
|
|
516
499
|
if (stats.steps === 0 && !hasTokens) return null
|
|
517
500
|
// data-composer-stats: through host 0.1.6-alpha.1 InputBar tightened the
|
|
518
501
|
// composer bottom clearance while this row rendered (the official pills'
|
|
@@ -585,8 +568,7 @@ export const StatsUsageEntry = memo(function StatsUsageEntry(
|
|
|
585
568
|
) {
|
|
586
569
|
const usage = useProjection('tokenUsage')
|
|
587
570
|
const tree = useSessionUsage(sessionId, usageSignature(usage, usage === undefined ? 0 : 1))
|
|
588
|
-
const hasTokens = usage
|
|
589
|
-
&& (billedInputTokens(usage) > 0 || usage.outputTokens > 0)
|
|
571
|
+
const hasTokens = hasAnyTokens(sessionUsageForDisplay(usage, tree.tree).total)
|
|
590
572
|
if (!hasTokens) return null
|
|
591
573
|
return (
|
|
592
574
|
<UsagePill
|
|
@@ -499,11 +499,26 @@
|
|
|
499
499
|
font-size: 11px;
|
|
500
500
|
}
|
|
501
501
|
|
|
502
|
-
.
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
502
|
+
/* Two ranking cards, side by side: MODELS left, PROVIDERS right. Both are
|
|
503
|
+
always on screen (no lens/tab switch), so they share one row grid and
|
|
504
|
+
stretch to a common height; a narrow pane stacks them instead. */
|
|
505
|
+
.rankCards {
|
|
506
|
+
display: grid;
|
|
507
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
508
|
+
gap: 12px;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
@media (max-width: 720px) {
|
|
512
|
+
.rankCards {
|
|
513
|
+
grid-template-columns: minmax(0, 1fr);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/* Each card is the bordered surface its rows live in; the title is a recessed
|
|
518
|
+
header strip, so the two rankings read as two equal panels. */
|
|
519
|
+
.rankCard {
|
|
520
|
+
display: flex;
|
|
521
|
+
flex-direction: column;
|
|
507
522
|
min-width: 0;
|
|
508
523
|
border: 1px solid var(--dsw-alias-border-l1);
|
|
509
524
|
border-radius: 10px;
|
|
@@ -511,6 +526,29 @@
|
|
|
511
526
|
overflow: hidden;
|
|
512
527
|
}
|
|
513
528
|
|
|
529
|
+
.rankCardTitle {
|
|
530
|
+
padding: 8px 12px;
|
|
531
|
+
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
|
532
|
+
background: color-mix(in srgb, var(--dsw-alias-bg-layer-2) 55%, transparent);
|
|
533
|
+
color: var(--dsw-alias-label-secondary);
|
|
534
|
+
font-size: 12px;
|
|
535
|
+
font-weight: 600;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
.rankList {
|
|
539
|
+
list-style: none;
|
|
540
|
+
margin: 0;
|
|
541
|
+
padding: 0;
|
|
542
|
+
flex: 1 1 auto;
|
|
543
|
+
min-width: 0;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/* Every row opens a hover tip (the cross-dimension detail), so it carries the
|
|
547
|
+
same hover affordance as the chart marks. */
|
|
548
|
+
.modelRow:hover {
|
|
549
|
+
background: color-mix(in srgb, var(--dsw-alias-bg-layer-2) 60%, transparent);
|
|
550
|
+
}
|
|
551
|
+
|
|
514
552
|
/* Position column: the row's rank inside its section. The Other bucket holds
|
|
515
553
|
no single rank, so it renders an empty (but equally wide) cell. */
|
|
516
554
|
.modelRank {
|
|
@@ -667,6 +705,35 @@
|
|
|
667
705
|
margin-bottom: 4px;
|
|
668
706
|
}
|
|
669
707
|
|
|
708
|
+
/* A provider row's raw route id, kept out of the row label but reachable on
|
|
709
|
+
hover (the row shows the display name). */
|
|
710
|
+
.tipSubtitle {
|
|
711
|
+
margin: -2px 0 4px;
|
|
712
|
+
color: var(--dsw-alias-label-tertiary);
|
|
713
|
+
font-size: 12px;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/* Breakdown heading inside a row tip: names the cross-dimension list under it
|
|
717
|
+
("By provider" under a model, "By model" under a provider). */
|
|
718
|
+
.tipLabel {
|
|
719
|
+
margin-bottom: 2px;
|
|
720
|
+
color: var(--dsw-alias-label-tertiary);
|
|
721
|
+
font-size: 12px;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/* The breakdown line pushes its volume to the right edge of the plate. */
|
|
725
|
+
.tipName {
|
|
726
|
+
flex: 1 1 auto;
|
|
727
|
+
min-width: 0;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
.tipValue {
|
|
731
|
+
margin-left: auto;
|
|
732
|
+
padding-left: 12px;
|
|
733
|
+
color: var(--dsw-alias-label-secondary);
|
|
734
|
+
font-variant-numeric: tabular-nums;
|
|
735
|
+
}
|
|
736
|
+
|
|
670
737
|
.tipRow {
|
|
671
738
|
display: flex;
|
|
672
739
|
align-items: center;
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from 'react'
|
|
22
22
|
import clsx from 'clsx'
|
|
23
|
-
import { Activity, ArrowDownToLine, ArrowUpFromLine,
|
|
23
|
+
import { Activity, ArrowDownToLine, ArrowUpFromLine, Coins, Database, MessagesSquare } from 'lucide-react'
|
|
24
24
|
import { Button, Input, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
25
25
|
import type { DailyTokenUsage, ModelTokenUsage, ProviderTokenUsage, TokenTotalRange, TokenTotalRequest } from '../wire.ts'
|
|
26
26
|
import { fetchRange, UsageApiError } from './api.ts'
|
|
@@ -52,8 +52,8 @@ const TOP_MODELS = 10
|
|
|
52
52
|
const OTHER_MODEL = '\u0000other' // sentinel; cannot collide with a real model ref
|
|
53
53
|
const OTHER_COLOR = 'var(--dsw-chart-other)'
|
|
54
54
|
|
|
55
|
-
/** How many rows each
|
|
56
|
-
const TOP_BREAKDOWN =
|
|
55
|
+
/** How many rows each ranking card lists (扩展到 8 个排行). */
|
|
56
|
+
const TOP_BREAKDOWN = 8
|
|
57
57
|
|
|
58
58
|
/** Shared empty key set: one stable identity for a collapsed expand state. */
|
|
59
59
|
const NO_KEYS: ReadonlySet<string> = new Set()
|
|
@@ -254,7 +254,14 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
|
|
|
254
254
|
<StatCards stats={stats} t={t} />
|
|
255
255
|
<Heatmap daily={heatDaily} from={heatWindow.from} to={heatWindow.to} t={t} panelRef={panelRef} />
|
|
256
256
|
<DailyTrend models={trendModels} daily={trendDaily} granularity={stats?.granularity ?? 'day'} t={t} colorForModel={colorForModel} panelRef={panelRef} />
|
|
257
|
-
<UsageBreakdown
|
|
257
|
+
<UsageBreakdown
|
|
258
|
+
models={stats.models}
|
|
259
|
+
providers={stats.providers}
|
|
260
|
+
total={stats.tokens}
|
|
261
|
+
providerNames={stats.providerNames}
|
|
262
|
+
t={t}
|
|
263
|
+
panelRef={panelRef}
|
|
264
|
+
/>
|
|
258
265
|
{stats.to && (
|
|
259
266
|
<div className={css.foot}>
|
|
260
267
|
{t('asOf')} {stats.to}
|
|
@@ -683,104 +690,243 @@ function DailyTrend({ models, daily, granularity, t, colorForModel, panelRef }:
|
|
|
683
690
|
)
|
|
684
691
|
}
|
|
685
692
|
|
|
686
|
-
// ── Section 6: bottom
|
|
693
|
+
// ── Section 6: bottom ranking — top 8 by model / by provider ──────────────
|
|
687
694
|
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
695
|
+
// Two side-by-side cards, always on screen (no tabs): the left card ranks
|
|
696
|
+
// MODELS, the right ranks PROVIDERS, each listing its top 8 rows.
|
|
690
697
|
//
|
|
691
|
-
// The by-model
|
|
698
|
+
// The by-model card aggregates by MODEL NAME, not by the provider/model ref:
|
|
692
699
|
// the same model served through two providers must show as ONE row
|
|
693
|
-
// (同名模型不同供应商需要聚合). The by-provider
|
|
694
|
-
// provider upstream, so it ranks the provider rows the host returned
|
|
700
|
+
// (同名模型不同供应商需要聚合). The by-provider card is already aggregated by
|
|
701
|
+
// provider upstream, so it ranks the provider rows the host returned, labelled
|
|
702
|
+
// with the selector's display name (`providerNames`).
|
|
703
|
+
//
|
|
704
|
+
// Both cards keep the same row anatomy — rank, swatch, name, tokens, share,
|
|
705
|
+
// cache hit rate — and move the cross-dimension detail into the hover tip: a
|
|
706
|
+
// model row explains which providers served it, a provider row explains which
|
|
707
|
+
// models it served. Hovering therefore never displaces the share column.
|
|
708
|
+
|
|
709
|
+
/** One child line of a row's hover detail (a provider under a model, or a
|
|
710
|
+
* model under a provider). `tokens` is null when the host payload predates
|
|
711
|
+
* the cross-dimension split and only the source NAME is known. */
|
|
712
|
+
interface RankChild {
|
|
713
|
+
key: string
|
|
714
|
+
name: string
|
|
715
|
+
tokens: number | null
|
|
716
|
+
color: string
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** The hover detail of one ranked row. The tip itself renders once, in
|
|
720
|
+
* UsageBreakdown, so only one row can be explained at a time. */
|
|
721
|
+
interface RankTip {
|
|
722
|
+
anchor: Element
|
|
723
|
+
title: string
|
|
724
|
+
/** The raw route id, set only when it differs from the display label. */
|
|
725
|
+
subtitle?: string
|
|
726
|
+
tokens: number
|
|
727
|
+
cacheHit: number
|
|
728
|
+
cacheMiss: number
|
|
729
|
+
breakdownLabel: string
|
|
730
|
+
breakdown: RankChild[]
|
|
731
|
+
}
|
|
695
732
|
|
|
696
733
|
interface BreakdownRow {
|
|
697
734
|
key: string
|
|
698
735
|
name: string
|
|
699
|
-
|
|
736
|
+
subtitle?: string
|
|
700
737
|
tokens: number
|
|
701
738
|
cacheHit: number
|
|
702
739
|
cacheMiss: number
|
|
703
740
|
color: string
|
|
741
|
+
breakdownLabel: string
|
|
742
|
+
breakdown: RankChild[]
|
|
704
743
|
}
|
|
705
744
|
|
|
706
|
-
|
|
745
|
+
/** Provider hues run 1..5 then the gray tail (a provider beyond the palette is
|
|
746
|
+
* rare: the card lists 8 rows, the palette names 5). */
|
|
747
|
+
function providerColor(index: number): string {
|
|
748
|
+
return index < 5 ? `var(--dsw-provider-${index + 1})` : 'var(--dsw-provider-other)'
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Model hues run 1..10 then the gray Other step (mirrors colorForModel). */
|
|
752
|
+
function modelColor(index: number): string {
|
|
753
|
+
return index < TOP_MODELS ? `var(--dsw-chart-${index + 1})` : OTHER_COLOR
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function UsageBreakdown({ models, providers, total, providerNames, t, panelRef }: {
|
|
707
757
|
models: ModelTokenUsage[]
|
|
708
758
|
providers: ProviderTokenUsage[]
|
|
709
759
|
total: number
|
|
760
|
+
providerNames?: Record<string, string>
|
|
710
761
|
t: Translator
|
|
762
|
+
panelRef: RefObject<HTMLDivElement | null>
|
|
711
763
|
}) {
|
|
712
|
-
const [
|
|
764
|
+
const [tip, setTip] = useState<RankTip | null>(null)
|
|
765
|
+
|
|
766
|
+
// The display label for a provider route: the host-resolved selector name
|
|
767
|
+
// when there is one, else the raw historical id (a deleted/renamed route).
|
|
768
|
+
// The typeof guard keeps a prototype-shaped key ("constructor") from being
|
|
769
|
+
// read as a label.
|
|
770
|
+
const providerLabel = useCallback((provider: string): string => {
|
|
771
|
+
const named = providerNames?.[provider]
|
|
772
|
+
return typeof named === 'string' && named.trim() !== '' ? named : provider
|
|
773
|
+
}, [providerNames])
|
|
774
|
+
|
|
775
|
+
const shareText = (n: number): string => (total > 0 ? formatPercent((n / total) * 100) : '—')
|
|
713
776
|
|
|
714
777
|
const modelRows = useMemo<BreakdownRow[]>(() => {
|
|
715
|
-
const byName = new Map<string, {
|
|
778
|
+
const byName = new Map<string, {
|
|
779
|
+
tokens: number
|
|
780
|
+
cacheHit: number
|
|
781
|
+
cacheMiss: number
|
|
782
|
+
/** Provider -> tokens, from the host's cross-dimension split. */
|
|
783
|
+
split: Map<string, number>
|
|
784
|
+
/** Every provider the host named, split or not. */
|
|
785
|
+
sources: Set<string>
|
|
786
|
+
}>()
|
|
716
787
|
for (const m of models) {
|
|
717
788
|
const name = m.model
|
|
718
|
-
const acc = byName.get(name) ?? { tokens: 0, cacheHit: 0, cacheMiss: 0,
|
|
789
|
+
const acc = byName.get(name) ?? { tokens: 0, cacheHit: 0, cacheMiss: 0, split: new Map<string, number>(), sources: new Set<string>() }
|
|
719
790
|
acc.tokens += m.tokens
|
|
720
791
|
acc.cacheHit += m.cacheHit
|
|
721
792
|
acc.cacheMiss += m.cacheMiss
|
|
722
|
-
for (const
|
|
793
|
+
for (const item of m.providerTokens ?? []) {
|
|
794
|
+
acc.split.set(item.provider, (acc.split.get(item.provider) ?? 0) + item.tokens)
|
|
795
|
+
}
|
|
796
|
+
for (const provider of m.providers ?? (m.provider === '' ? [] : [m.provider])) acc.sources.add(provider)
|
|
723
797
|
byName.set(name, acc)
|
|
724
798
|
}
|
|
725
799
|
return [...byName.entries()]
|
|
726
800
|
.sort((a, b) => b[1].tokens - a[1].tokens)
|
|
727
801
|
.slice(0, TOP_BREAKDOWN)
|
|
728
|
-
.map(([name, acc], i) =>
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
802
|
+
.map(([name, acc], i) => {
|
|
803
|
+
const split: RankChild[] = [...acc.split.entries()]
|
|
804
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
805
|
+
.map(([provider, tokens], j) => ({ key: provider, name: providerLabel(provider), tokens, color: providerColor(j) }))
|
|
806
|
+
// A payload from before the split still names its sources; list them
|
|
807
|
+
// without a volume rather than inventing a number.
|
|
808
|
+
const known = new Set(split.map((child) => child.key))
|
|
809
|
+
const named: RankChild[] = [...acc.sources]
|
|
810
|
+
.filter((provider) => !known.has(provider))
|
|
811
|
+
.sort()
|
|
812
|
+
.map((provider, j) => ({ key: provider, name: providerLabel(provider), tokens: null, color: providerColor(split.length + j) }))
|
|
813
|
+
return {
|
|
814
|
+
key: name,
|
|
815
|
+
name,
|
|
816
|
+
tokens: acc.tokens,
|
|
817
|
+
cacheHit: acc.cacheHit,
|
|
818
|
+
cacheMiss: acc.cacheMiss,
|
|
819
|
+
color: modelColor(i),
|
|
820
|
+
breakdownLabel: t('byProvider'),
|
|
821
|
+
breakdown: [...split, ...named],
|
|
822
|
+
}
|
|
823
|
+
})
|
|
824
|
+
}, [models, providerLabel, t])
|
|
738
825
|
|
|
739
826
|
const providerRows = useMemo<BreakdownRow[]>(() => {
|
|
740
827
|
return [...providers]
|
|
741
828
|
.filter((p) => p.tokens > 0)
|
|
742
829
|
.sort((a, b) => b.tokens - a.tokens)
|
|
743
830
|
.slice(0, TOP_BREAKDOWN)
|
|
744
|
-
.map((p, i) =>
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
831
|
+
.map((p, i) => {
|
|
832
|
+
const label = providerLabel(p.provider)
|
|
833
|
+
return {
|
|
834
|
+
key: p.provider,
|
|
835
|
+
name: label,
|
|
836
|
+
// Keep the route id reachable: the row shows the display name only.
|
|
837
|
+
subtitle: label === p.provider ? undefined : p.provider,
|
|
838
|
+
tokens: p.tokens,
|
|
839
|
+
cacheHit: p.cacheHit,
|
|
840
|
+
cacheMiss: p.cacheMiss,
|
|
841
|
+
color: providerColor(i),
|
|
842
|
+
breakdownLabel: t('byModel'),
|
|
843
|
+
breakdown: [...(p.modelTokens ?? [])]
|
|
844
|
+
.sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model))
|
|
845
|
+
.map((m, j) => ({ key: m.model, name: m.model, tokens: m.tokens, color: modelColor(j) })),
|
|
846
|
+
}
|
|
847
|
+
})
|
|
848
|
+
}, [providers, providerLabel, t])
|
|
756
849
|
|
|
757
850
|
return (
|
|
758
851
|
<section className={css.section}>
|
|
759
852
|
<div className={css.sectionHead}>
|
|
760
|
-
{/* One
|
|
761
|
-
|
|
762
|
-
section rather than the current lens. */}
|
|
853
|
+
{/* One heading for the whole section: both rankings are always on
|
|
854
|
+
screen, side by side, so there is no lens to switch. */}
|
|
763
855
|
<h3 className={css.sectionTitle}>{t('usageRanking')}</h3>
|
|
764
|
-
<div className={css.group} role="tablist" aria-label={t('usageRanking')}>
|
|
765
|
-
<Button size="sm" variant="ghost" role="tab" aria-selected={tab === 'model'} className={clsx(css.segItem, tab === 'model' && css.segActive)} onClick={() => setTab('model')}>
|
|
766
|
-
{t('byModel')}
|
|
767
|
-
</Button>
|
|
768
|
-
<Button size="sm" variant="ghost" role="tab" aria-selected={tab === 'provider'} className={clsx(css.segItem, tab === 'provider' && css.segActive)} onClick={() => setTab('provider')}>
|
|
769
|
-
{t('byProvider')}
|
|
770
|
-
</Button>
|
|
771
|
-
</div>
|
|
772
856
|
</div>
|
|
857
|
+
<div className={css.rankCards}>
|
|
858
|
+
<RankCard title={t('byModel')} rows={modelRows} total={total} t={t} onTip={setTip} />
|
|
859
|
+
<RankCard title={t('byProvider')} rows={providerRows} total={total} t={t} onTip={setTip} />
|
|
860
|
+
</div>
|
|
861
|
+
<ChartTip anchor={tip?.anchor ?? null} panelRef={panelRef}>
|
|
862
|
+
{tip && (
|
|
863
|
+
<>
|
|
864
|
+
<div className={css.tipTitle}>{tip.title}</div>
|
|
865
|
+
{tip.subtitle !== undefined && <div className={css.tipSubtitle}>{tip.subtitle}</div>}
|
|
866
|
+
<div className={css.tipRow}>{t('total')} {formatTokens(tip.tokens)} · {shareText(tip.tokens)}</div>
|
|
867
|
+
<div className={css.tipRow}>{t('cacheRateColumn')} {cacheRateText(tip.cacheHit, tip.cacheMiss)}</div>
|
|
868
|
+
{tip.breakdown.length > 0 && (
|
|
869
|
+
<div className={css.tipBreakdown}>
|
|
870
|
+
<div className={css.tipLabel}>{tip.breakdownLabel}</div>
|
|
871
|
+
{tip.breakdown.map((child) => (
|
|
872
|
+
<div key={child.key} className={css.tipRow}>
|
|
873
|
+
<i className={css.legendSwatch} style={{ background: child.color }} />
|
|
874
|
+
<span className={css.tipName}>{child.name}</span>
|
|
875
|
+
<span className={css.tipValue}>
|
|
876
|
+
{child.tokens === null ? '' : `${formatCompact(child.tokens)} · ${shareText(child.tokens)}`}
|
|
877
|
+
</span>
|
|
878
|
+
</div>
|
|
879
|
+
))}
|
|
880
|
+
</div>
|
|
881
|
+
)}
|
|
882
|
+
</>
|
|
883
|
+
)}
|
|
884
|
+
</ChartTip>
|
|
885
|
+
</section>
|
|
886
|
+
)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** One ranking card: a titled list whose rows explain themselves on hover.
|
|
890
|
+
* The tip is owned by UsageBreakdown (one open tip at a time), so a row only
|
|
891
|
+
* reports which row it is, never where to draw. */
|
|
892
|
+
function RankCard({ title, rows, total, t, onTip }: {
|
|
893
|
+
title: string
|
|
894
|
+
rows: BreakdownRow[]
|
|
895
|
+
total: number
|
|
896
|
+
t: Translator
|
|
897
|
+
onTip: (tip: RankTip | null) => void
|
|
898
|
+
}) {
|
|
899
|
+
const share = (n: number): string => (total > 0 ? formatPercent((n / total) * 100) : '—')
|
|
900
|
+
return (
|
|
901
|
+
<div className={css.rankCard}>
|
|
902
|
+
<div className={css.rankCardTitle}>{title}</div>
|
|
773
903
|
{rows.length === 0 ? (
|
|
774
904
|
<div className={css.empty}>{t('empty')}</div>
|
|
775
905
|
) : (
|
|
776
|
-
<ul className={css.
|
|
906
|
+
<ul className={css.rankList} data-dsh-usage-glass="list">
|
|
777
907
|
{rows.map((r, rank) => (
|
|
778
|
-
<li
|
|
908
|
+
<li
|
|
909
|
+
key={r.key}
|
|
910
|
+
className={css.modelRow}
|
|
911
|
+
onMouseEnter={(e) => onTip({
|
|
912
|
+
anchor: e.currentTarget,
|
|
913
|
+
title: r.name,
|
|
914
|
+
subtitle: r.subtitle,
|
|
915
|
+
tokens: r.tokens,
|
|
916
|
+
cacheHit: r.cacheHit,
|
|
917
|
+
cacheMiss: r.cacheMiss,
|
|
918
|
+
breakdownLabel: r.breakdownLabel,
|
|
919
|
+
breakdown: r.breakdown,
|
|
920
|
+
})}
|
|
921
|
+
onMouseLeave={() => onTip(null)}
|
|
922
|
+
>
|
|
779
923
|
<span className={css.modelRank} aria-hidden="true">{rank + 1}</span>
|
|
780
924
|
<i className={css.legendSwatch} style={{ background: r.color }} />
|
|
781
925
|
<div className={css.modelId}>
|
|
782
926
|
<span className={css.modelName}>{r.name}</span>
|
|
783
|
-
|
|
927
|
+
{/* The share keeps its own line: the hover detail never
|
|
928
|
+
displaces it (悬浮详情不占用百分比的位置). */}
|
|
929
|
+
<span className={css.modelProvider}>{share(r.tokens)}</span>
|
|
784
930
|
</div>
|
|
785
931
|
<div className={css.modelValues}>
|
|
786
932
|
<span className={css.modelTokens}>{formatCompact(r.tokens)}</span>
|
|
@@ -790,7 +936,7 @@ function UsageBreakdown({ models, providers, total, t }: {
|
|
|
790
936
|
))}
|
|
791
937
|
</ul>
|
|
792
938
|
)}
|
|
793
|
-
</
|
|
939
|
+
</div>
|
|
794
940
|
)
|
|
795
941
|
}
|
|
796
942
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Pure billing helpers shared by the session-tree hook and composer pills. */
|
|
2
|
+
import type { SessionUsageTotals, SessionUsageTree } from '../wire.ts'
|
|
3
|
+
|
|
4
|
+
const ZERO: SessionUsageTotals = {
|
|
5
|
+
uncachedInputTokens: 0,
|
|
6
|
+
outputTokens: 0,
|
|
7
|
+
cacheReadTokens: 0,
|
|
8
|
+
cacheWriteTokens: 0,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Use the live main-agent projection when available. Never add tree.total to
|
|
12
|
+
* it: that would count the main agent twice. The log is only the fallback. */
|
|
13
|
+
export function combinedTotals(tree: SessionUsageTree, self: SessionUsageTotals = tree.self): SessionUsageTotals {
|
|
14
|
+
return {
|
|
15
|
+
uncachedInputTokens: self.uncachedInputTokens + tree.subagents.uncachedInputTokens,
|
|
16
|
+
outputTokens: self.outputTokens + tree.subagents.outputTokens,
|
|
17
|
+
cacheReadTokens: self.cacheReadTokens + tree.subagents.cacheReadTokens,
|
|
18
|
+
cacheWriteTokens: self.cacheWriteTokens + tree.subagents.cacheWriteTokens,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One consistent split for the pill, cache hit rate and every dialog row. */
|
|
23
|
+
export function sessionUsageForDisplay(
|
|
24
|
+
native: SessionUsageTotals | undefined,
|
|
25
|
+
tree: SessionUsageTree | null,
|
|
26
|
+
): SessionUsageTree {
|
|
27
|
+
const extra = tree?.selfCompaction ?? ZERO
|
|
28
|
+
// Native tokenUsage counts message/attempt calls, but not compaction calls.
|
|
29
|
+
// tree.self already includes compaction, so add the supplement ONLY to native.
|
|
30
|
+
const self = native === undefined ? tree?.self ?? { ...ZERO } : {
|
|
31
|
+
uncachedInputTokens: native.uncachedInputTokens + extra.uncachedInputTokens,
|
|
32
|
+
outputTokens: native.outputTokens + extra.outputTokens,
|
|
33
|
+
cacheReadTokens: native.cacheReadTokens + extra.cacheReadTokens,
|
|
34
|
+
cacheWriteTokens: native.cacheWriteTokens + extra.cacheWriteTokens,
|
|
35
|
+
}
|
|
36
|
+
const subagents = tree?.subagents ?? { ...ZERO }
|
|
37
|
+
return {
|
|
38
|
+
self,
|
|
39
|
+
subagents,
|
|
40
|
+
total: tree === null ? self : combinedTotals(tree, self),
|
|
41
|
+
subagentSessions: tree?.subagentSessions ?? 0,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function hasAnyTokens(totals: SessionUsageTotals | undefined): boolean {
|
|
46
|
+
return totals !== undefined && billedTotal(totals) > 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function billedTotal(totals: SessionUsageTotals): number {
|
|
50
|
+
return totals.uncachedInputTokens + totals.cacheReadTokens
|
|
51
|
+
+ totals.cacheWriteTokens + totals.outputTokens
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function cacheHitPercentOf(totals: SessionUsageTotals): number | null {
|
|
55
|
+
const hit = totals.cacheReadTokens
|
|
56
|
+
const miss = totals.uncachedInputTokens + totals.cacheWriteTokens
|
|
57
|
+
return hit + miss === 0 ? null : (hit / (hit + miss)) * 100
|
|
58
|
+
}
|