opencode-cache-hit 0.1.0 → 0.2.0
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/AGENTS.md +2 -1
- package/CONTRIBUTING.md +24 -8
- package/README.md +51 -5
- package/README.zh-CN.md +8 -4
- package/cache-hit.config.example.json +7 -2
- package/docs/assets/.gitkeep +0 -0
- package/docs/en/design.md +8 -4
- package/docs/en/timeline.md +9 -8
- package/docs/zh-CN/design.md +8 -4
- package/docs/zh-CN/timeline.md +10 -9
- package/package.json +1 -2
- package/scripts/README.md +1 -1
- package/scripts/plot-hit-rate.ts +4 -3
- package/src/agents-view.tsx +16 -2
- package/src/cache-ttl-view.tsx +133 -0
- package/src/format-cost.ts +13 -1
- package/src/i18n.ts +18 -3
- package/src/load-config.ts +24 -5
- package/src/main-session-view.tsx +43 -3
- package/src/plugin-config.ts +59 -1
- package/src/plugin.tsx +4 -1
- package/src/pricing.ts +57 -0
- package/src/sidebar-host.tsx +7 -1
- package/src/stats.ts +6 -15
- package/src/timeline/records.ts +27 -8
- package/src/timeline/types.ts +3 -3
- package/src/timeline/writer.ts +5 -4
- package/src/types.ts +16 -0
- package/src/use-cache-hit-metrics.ts +8 -9
- package/src/widget.tsx +11 -3
package/src/format-cost.ts
CHANGED
|
@@ -26,7 +26,7 @@ export const CURRENCY_PRESETS: Record<CurrencyCode, { symbol: string; decimals:
|
|
|
26
26
|
export const DEFAULT_COST_DISPLAY: CostDisplayConfig = {
|
|
27
27
|
currency: "CNY",
|
|
28
28
|
costUnit: "USD",
|
|
29
|
-
rate:
|
|
29
|
+
rate: 6.77,
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function resolveExchangeRate(cfg: CostDisplayConfig): number {
|
|
@@ -88,3 +88,15 @@ export function createCostFormatter(config: CostDisplayConfig): (amountUsd: numb
|
|
|
88
88
|
return "~" + symbol + v.toFixed(decimals)
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
+
|
|
92
|
+
export function createRateFormatter(config: CostDisplayConfig): (perMillion: number) => string {
|
|
93
|
+
const preset = CURRENCY_PRESETS[config.currency]
|
|
94
|
+
const symbol = config.symbol ?? preset.symbol
|
|
95
|
+
const unit = config.costUnit ?? config.convert?.from ?? "USD"
|
|
96
|
+
const rate = unit === config.currency ? 1 : resolveExchangeRate(config)
|
|
97
|
+
|
|
98
|
+
return (perMillion: number) => {
|
|
99
|
+
const v = perMillion * rate
|
|
100
|
+
return symbol + v.toFixed(2)
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/i18n.ts
CHANGED
|
@@ -10,7 +10,11 @@ export type UiStrings = {
|
|
|
10
10
|
out: string
|
|
11
11
|
reasoning: string
|
|
12
12
|
cost: string
|
|
13
|
-
|
|
13
|
+
saved: string
|
|
14
|
+
rate: string
|
|
15
|
+
rateIn: string
|
|
16
|
+
rateOut: string
|
|
17
|
+
rateCache: string
|
|
14
18
|
hitFolded: string
|
|
15
19
|
noData: string
|
|
16
20
|
secDetail: string
|
|
@@ -19,6 +23,7 @@ export type UiStrings = {
|
|
|
19
23
|
secAgents: string
|
|
20
24
|
/** Shown in Agents section header: totals are child sessions only, not the parent session. */
|
|
21
25
|
agentsScopeHint: string
|
|
26
|
+
secTTL: string
|
|
22
27
|
tok: string
|
|
23
28
|
}
|
|
24
29
|
|
|
@@ -32,7 +37,11 @@ const EN: UiStrings = {
|
|
|
32
37
|
out: "Out:",
|
|
33
38
|
reasoning: "Reason:",
|
|
34
39
|
cost: "Cost:",
|
|
35
|
-
|
|
40
|
+
saved: "Saved:",
|
|
41
|
+
rate: "Rate:",
|
|
42
|
+
rateIn: "/M in",
|
|
43
|
+
rateOut: "/M out",
|
|
44
|
+
rateCache: "/M cache",
|
|
36
45
|
hitFolded: "hit",
|
|
37
46
|
noData: "Waiting for cache data...",
|
|
38
47
|
secDetail: "Detail",
|
|
@@ -40,6 +49,7 @@ const EN: UiStrings = {
|
|
|
40
49
|
model: "Model:",
|
|
41
50
|
secAgents: "Agents",
|
|
42
51
|
agentsScopeHint: " · sub-sessions",
|
|
52
|
+
secTTL: "TTL:",
|
|
43
53
|
tok: "tok",
|
|
44
54
|
}
|
|
45
55
|
|
|
@@ -53,7 +63,11 @@ const ZH: UiStrings = {
|
|
|
53
63
|
out: "输出:",
|
|
54
64
|
reasoning: "推理:",
|
|
55
65
|
cost: "费用:",
|
|
56
|
-
|
|
66
|
+
saved: "节省:",
|
|
67
|
+
rate: "单价:",
|
|
68
|
+
rateIn: "/M 输入",
|
|
69
|
+
rateOut: "/M 输出",
|
|
70
|
+
rateCache: "/M 缓存",
|
|
57
71
|
hitFolded: "命中",
|
|
58
72
|
noData: "等待缓存数据...",
|
|
59
73
|
secDetail: "明细",
|
|
@@ -61,6 +75,7 @@ const ZH: UiStrings = {
|
|
|
61
75
|
model: "模型:",
|
|
62
76
|
secAgents: "子 Agent",
|
|
63
77
|
agentsScopeHint: " · 仅子会话",
|
|
78
|
+
secTTL: "存活:",
|
|
64
79
|
tok: "tok",
|
|
65
80
|
}
|
|
66
81
|
|
package/src/load-config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
-
import {
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
3
4
|
import { fileURLToPath } from "node:url"
|
|
4
5
|
import {
|
|
5
6
|
type PluginConfig,
|
|
@@ -9,6 +10,11 @@ import {
|
|
|
9
10
|
|
|
10
11
|
/** Parent of `src/` (plugin package root). Do not wrap in `dirname` — `..` already resolves there. */
|
|
11
12
|
export const PLUGIN_ROOT = fileURLToPath(new URL("..", import.meta.url))
|
|
13
|
+
|
|
14
|
+
/** Preferred config: ~/.config/opencode/cache-hit.json (survives plugin updates). */
|
|
15
|
+
export const XDG_CONFIG_PATH = join(homedir(), ".config", "opencode", "cache-hit.json")
|
|
16
|
+
|
|
17
|
+
/** Legacy config: plugin-root `cache-hit.config.json` (for npm cache / local installs). */
|
|
12
18
|
export const CONFIG_PATH = join(PLUGIN_ROOT, "cache-hit.config.json")
|
|
13
19
|
|
|
14
20
|
function cloneDefault(): PluginConfig {
|
|
@@ -19,11 +25,24 @@ function cloneDefault(): PluginConfig {
|
|
|
19
25
|
}
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
if (!existsSync(CONFIG_PATH)) return cloneDefault()
|
|
28
|
+
function tryRead(path: string): PluginConfig | null {
|
|
24
29
|
try {
|
|
25
|
-
return normalizePluginConfig(JSON.parse(readFileSync(
|
|
30
|
+
return normalizePluginConfig(JSON.parse(readFileSync(path, "utf8")))
|
|
26
31
|
} catch {
|
|
27
|
-
return
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function loadPluginConfig(): PluginConfig {
|
|
37
|
+
// 1. XDG config dir (preferred — persists across updates)
|
|
38
|
+
if (existsSync(XDG_CONFIG_PATH)) {
|
|
39
|
+
const cfg = tryRead(XDG_CONFIG_PATH)
|
|
40
|
+
if (cfg) return cfg
|
|
41
|
+
}
|
|
42
|
+
// 2. Legacy plugin-root config (backward compatible)
|
|
43
|
+
if (existsSync(CONFIG_PATH)) {
|
|
44
|
+
const cfg = tryRead(CONFIG_PATH)
|
|
45
|
+
if (cfg) return cfg
|
|
28
46
|
}
|
|
47
|
+
return cloneDefault()
|
|
29
48
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { Show } from "solid-js"
|
|
3
3
|
import { TokenDetailRows } from "./cache-hit-rows.tsx"
|
|
4
|
+
import { CacheTTLView } from "./cache-ttl-view.tsx"
|
|
4
5
|
import type { CacheHitMetrics } from "./use-cache-hit-metrics.ts"
|
|
6
|
+
import type { CacheTTLConfig } from "./plugin-config.ts"
|
|
7
|
+
import type { AssistantMessage } from "./types.ts"
|
|
5
8
|
import {
|
|
6
9
|
TuiHitRow,
|
|
7
10
|
TuiMetricRow,
|
|
@@ -9,6 +12,7 @@ import {
|
|
|
9
12
|
type PanelLayout,
|
|
10
13
|
type SectionFold,
|
|
11
14
|
} from "./tui-panel/index.ts"
|
|
15
|
+
import type { Accessor } from "solid-js"
|
|
12
16
|
|
|
13
17
|
export function MainSessionView(props: {
|
|
14
18
|
m: CacheHitMetrics
|
|
@@ -16,6 +20,9 @@ export function MainSessionView(props: {
|
|
|
16
20
|
detail: SectionFold
|
|
17
21
|
model: SectionFold
|
|
18
22
|
formatCost: (n: number) => string
|
|
23
|
+
formatRate: (perMillion: number) => string
|
|
24
|
+
cacheTTL?: CacheTTLConfig
|
|
25
|
+
messages?: Accessor<AssistantMessage[]>
|
|
19
26
|
}) {
|
|
20
27
|
const { m, layout } = props
|
|
21
28
|
return (
|
|
@@ -31,6 +38,15 @@ export function MainSessionView(props: {
|
|
|
31
38
|
}
|
|
32
39
|
/>
|
|
33
40
|
<TuiMetricRow pal={m.pal()} layout={layout} label={m.t().totalHit} value={m.sessionPct()} />
|
|
41
|
+
<Show when={props.cacheTTL?.enabled && props.messages}>
|
|
42
|
+
<CacheTTLView
|
|
43
|
+
messages={props.messages!}
|
|
44
|
+
config={props.cacheTTL!}
|
|
45
|
+
pal={m.pal()}
|
|
46
|
+
layout={layout}
|
|
47
|
+
label={m.t().secTTL}
|
|
48
|
+
/>
|
|
49
|
+
</Show>
|
|
34
50
|
|
|
35
51
|
<TuiSection
|
|
36
52
|
pal={m.pal()}
|
|
@@ -40,12 +56,13 @@ export function MainSessionView(props: {
|
|
|
40
56
|
onToggle={props.detail.toggle}
|
|
41
57
|
>
|
|
42
58
|
<TokenDetailRows pal={m.pal()} layout={layout} t={m.t()} snap={m.main()}>
|
|
43
|
-
<Show when={m.
|
|
59
|
+
<Show when={m.pricing().saved > 0}>
|
|
44
60
|
<TuiMetricRow
|
|
45
61
|
pal={m.pal()}
|
|
46
62
|
layout={layout}
|
|
47
|
-
label={m.t().
|
|
48
|
-
value={m.
|
|
63
|
+
label={m.t().saved}
|
|
64
|
+
value={props.formatCost(m.pricing().saved)}
|
|
65
|
+
fg={m.pal().success}
|
|
49
66
|
/>
|
|
50
67
|
</Show>
|
|
51
68
|
</TokenDetailRows>
|
|
@@ -70,6 +87,29 @@ export function MainSessionView(props: {
|
|
|
70
87
|
<Show when={m.modelShort()}>
|
|
71
88
|
<TuiMetricRow pal={m.pal()} layout={layout} label={m.t().model} value={m.modelShort()} />
|
|
72
89
|
</Show>
|
|
90
|
+
<Show when={m.pricing().inputRate > 0}>
|
|
91
|
+
<TuiMetricRow
|
|
92
|
+
pal={m.pal()}
|
|
93
|
+
layout={layout}
|
|
94
|
+
label={m.t().rate}
|
|
95
|
+
value={`${props.formatRate(m.pricing().inputRate)}${m.t().rateIn}`}
|
|
96
|
+
fg={m.pal().muted}
|
|
97
|
+
/>
|
|
98
|
+
<TuiMetricRow
|
|
99
|
+
pal={m.pal()}
|
|
100
|
+
layout={layout}
|
|
101
|
+
label=""
|
|
102
|
+
value={`${props.formatRate(m.pricing().cacheReadRate)}${m.t().rateCache}`}
|
|
103
|
+
fg={m.pal().muted}
|
|
104
|
+
/>
|
|
105
|
+
<TuiMetricRow
|
|
106
|
+
pal={m.pal()}
|
|
107
|
+
layout={layout}
|
|
108
|
+
label=""
|
|
109
|
+
value={`${props.formatRate(m.pricing().outputRate)}${m.t().rateOut}`}
|
|
110
|
+
fg={m.pal().muted}
|
|
111
|
+
/>
|
|
112
|
+
</Show>
|
|
73
113
|
</TuiSection>
|
|
74
114
|
</>
|
|
75
115
|
)
|
package/src/plugin-config.ts
CHANGED
|
@@ -19,7 +19,7 @@ export const DEFAULT_DISPLAY: DisplayConfig = {
|
|
|
19
19
|
|
|
20
20
|
export type TimelineConfig = {
|
|
21
21
|
enabled: boolean
|
|
22
|
-
/** Empty →
|
|
22
|
+
/** Empty → `~/.local/share/opencode/logs/cache-hit`. Supports `~/…` expansion. */
|
|
23
23
|
dir: string
|
|
24
24
|
flushIncomplete: boolean
|
|
25
25
|
logSummaryMessages: boolean
|
|
@@ -49,16 +49,29 @@ export const DEFAULT_TIMELINE: TimelineConfig = {
|
|
|
49
49
|
maxLogFiles: 0,
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export type CacheTTLConfig = {
|
|
53
|
+
enabled: boolean
|
|
54
|
+
/** TTL per provider (or provider:model). Values like "5m", "1h", "30s". Falls back to built-in defaults. */
|
|
55
|
+
providers: Record<string, string>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const DEFAULT_CACHE_TTL: CacheTTLConfig = {
|
|
59
|
+
enabled: true,
|
|
60
|
+
providers: {},
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
export type PluginConfig = {
|
|
53
64
|
cost: CostDisplayConfig
|
|
54
65
|
display: DisplayConfig
|
|
55
66
|
timeline: TimelineConfig
|
|
67
|
+
cacheTTL: CacheTTLConfig
|
|
56
68
|
}
|
|
57
69
|
|
|
58
70
|
export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
|
|
59
71
|
cost: { ...DEFAULT_COST_DISPLAY },
|
|
60
72
|
display: { ...DEFAULT_DISPLAY },
|
|
61
73
|
timeline: { ...DEFAULT_TIMELINE },
|
|
74
|
+
cacheTTL: { ...DEFAULT_CACHE_TTL },
|
|
62
75
|
}
|
|
63
76
|
|
|
64
77
|
export function normalizeTimelineConfig(raw: unknown): TimelineConfig {
|
|
@@ -103,6 +116,50 @@ export function normalizeDisplayConfig(raw: unknown): DisplayConfig {
|
|
|
103
116
|
return d
|
|
104
117
|
}
|
|
105
118
|
|
|
119
|
+
export function normalizeCacheTTLConfig(raw: unknown): CacheTTLConfig {
|
|
120
|
+
const t: CacheTTLConfig = { enabled: DEFAULT_CACHE_TTL.enabled, providers: {} }
|
|
121
|
+
if (!raw || typeof raw !== "object") return t
|
|
122
|
+
const o = raw as Record<string, unknown>
|
|
123
|
+
if (typeof o.enabled === "boolean") t.enabled = o.enabled
|
|
124
|
+
if (o.providers && typeof o.providers === "object") {
|
|
125
|
+
const providers = o.providers as Record<string, unknown>
|
|
126
|
+
for (const [key, value] of Object.entries(providers)) {
|
|
127
|
+
if (typeof value === "string") {
|
|
128
|
+
t.providers[key] = value
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return t
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const TIME_UNITS: Record<string, number> = {
|
|
136
|
+
s: 1000,
|
|
137
|
+
sec: 1000,
|
|
138
|
+
second: 1000,
|
|
139
|
+
seconds: 1000,
|
|
140
|
+
m: 60_000,
|
|
141
|
+
min: 60_000,
|
|
142
|
+
minute: 60_000,
|
|
143
|
+
minutes: 60_000,
|
|
144
|
+
h: 3_600_000,
|
|
145
|
+
hr: 3_600_000,
|
|
146
|
+
hour: 3_600_000,
|
|
147
|
+
hours: 3_600_000,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function parseDuration(raw: string): number | null {
|
|
151
|
+
const match = raw.trim().match(/^(\d+(?:\.\d+)?)\s*([a-z]+)$/i)
|
|
152
|
+
if (!match) {
|
|
153
|
+
const num = Number(raw)
|
|
154
|
+
return Number.isFinite(num) && num > 0 ? Math.floor(num) : null
|
|
155
|
+
}
|
|
156
|
+
const value = Number(match[1])
|
|
157
|
+
const unit = match[2].toLowerCase()
|
|
158
|
+
const multiplier = TIME_UNITS[unit]
|
|
159
|
+
if (!multiplier || !Number.isFinite(value) || value <= 0) return null
|
|
160
|
+
return Math.floor(value * multiplier)
|
|
161
|
+
}
|
|
162
|
+
|
|
106
163
|
export function normalizePluginConfig(raw: unknown): PluginConfig {
|
|
107
164
|
if (!raw || typeof raw !== "object") return { ...DEFAULT_PLUGIN_CONFIG }
|
|
108
165
|
const o = raw as Record<string, unknown>
|
|
@@ -112,5 +169,6 @@ export function normalizePluginConfig(raw: unknown): PluginConfig {
|
|
|
112
169
|
cost,
|
|
113
170
|
display: normalizeDisplayConfig(displayRaw),
|
|
114
171
|
timeline: normalizeTimelineConfig(o.timeline),
|
|
172
|
+
cacheTTL: normalizeCacheTTLConfig(o.cacheTTL),
|
|
115
173
|
}
|
|
116
174
|
}
|
package/src/plugin.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { CacheHitSidebarHost } from "./sidebar-host.tsx"
|
|
3
3
|
import { loadPluginConfig } from "./load-config.ts"
|
|
4
|
-
import { createCostFormatter } from "./format-cost.ts"
|
|
4
|
+
import { createCostFormatter, createRateFormatter } from "./format-cost.ts"
|
|
5
5
|
import type { OpenCodeTuiApi } from "./types.ts"
|
|
6
6
|
|
|
7
7
|
export const PLUGIN_ID = "opencode-cache-hit"
|
|
@@ -9,6 +9,7 @@ export const PLUGIN_ID = "opencode-cache-hit"
|
|
|
9
9
|
export const tui = async (api: OpenCodeTuiApi) => {
|
|
10
10
|
const pluginConfig = loadPluginConfig()
|
|
11
11
|
const formatCost = createCostFormatter(pluginConfig.cost)
|
|
12
|
+
const formatRate = createRateFormatter(pluginConfig.cost)
|
|
12
13
|
|
|
13
14
|
api.slots.register({
|
|
14
15
|
order: 56,
|
|
@@ -20,7 +21,9 @@ export const tui = async (api: OpenCodeTuiApi) => {
|
|
|
20
21
|
theme={ctx.theme.current}
|
|
21
22
|
display={pluginConfig.display}
|
|
22
23
|
timeline={pluginConfig.timeline}
|
|
24
|
+
cacheTTL={pluginConfig.cacheTTL}
|
|
23
25
|
formatCost={formatCost}
|
|
26
|
+
formatRate={formatRate}
|
|
24
27
|
api={api}
|
|
25
28
|
/>
|
|
26
29
|
)
|
package/src/pricing.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ModelCost, ProviderInfo, SubAgentSummary } from "./types.ts"
|
|
2
|
+
|
|
3
|
+
export type PricingInfo = {
|
|
4
|
+
inputRate: number
|
|
5
|
+
outputRate: number
|
|
6
|
+
cacheReadRate: number
|
|
7
|
+
cacheWriteRate: number
|
|
8
|
+
saved: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const EMPTY_PRICING: PricingInfo = {
|
|
12
|
+
inputRate: 0,
|
|
13
|
+
outputRate: 0,
|
|
14
|
+
cacheReadRate: 0,
|
|
15
|
+
cacheWriteRate: 0,
|
|
16
|
+
saved: 0,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function lookupModelCost(
|
|
20
|
+
providers: ReadonlyArray<ProviderInfo>,
|
|
21
|
+
providerID: string | undefined,
|
|
22
|
+
modelID: string | undefined,
|
|
23
|
+
): ModelCost | null {
|
|
24
|
+
if (!providerID || !modelID) return null
|
|
25
|
+
for (const p of providers) {
|
|
26
|
+
if (p.id !== providerID) continue
|
|
27
|
+
const model = p.models[modelID]
|
|
28
|
+
return model?.cost ?? null
|
|
29
|
+
}
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function computePricing(
|
|
34
|
+
providers: ReadonlyArray<ProviderInfo>,
|
|
35
|
+
providerID: string | undefined,
|
|
36
|
+
modelID: string | undefined,
|
|
37
|
+
cacheRead: number,
|
|
38
|
+
): PricingInfo {
|
|
39
|
+
const cost = lookupModelCost(providers, providerID, modelID)
|
|
40
|
+
if (!cost) return EMPTY_PRICING
|
|
41
|
+
const inputRate = cost.input
|
|
42
|
+
const outputRate = cost.output
|
|
43
|
+
const cacheReadRate = cost.cache.read
|
|
44
|
+
const cacheWriteRate = cost.cache.write
|
|
45
|
+
const saved =
|
|
46
|
+
inputRate > cacheReadRate ? (cacheRead * (inputRate - cacheReadRate)) / 1_000_000 : 0
|
|
47
|
+
return { inputRate, outputRate, cacheReadRate, cacheWriteRate, saved }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function computeSubsSaved(subs: readonly SubAgentSummary[], providers: ReadonlyArray<ProviderInfo>): number {
|
|
51
|
+
let total = 0
|
|
52
|
+
for (const sub of subs) {
|
|
53
|
+
const p = computePricing(providers, sub.providerID, sub.model, sub.cacheRead)
|
|
54
|
+
total += p.saved
|
|
55
|
+
}
|
|
56
|
+
return total
|
|
57
|
+
}
|
package/src/sidebar-host.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
|
3
3
|
import { CacheHitSidebar } from "./widget.tsx"
|
|
4
|
-
import type { DisplayConfig, TimelineConfig } from "./plugin-config.ts"
|
|
4
|
+
import type { DisplayConfig, TimelineConfig, CacheTTLConfig } from "./plugin-config.ts"
|
|
5
5
|
import { createTimelineCollector } from "./timeline/collector.ts"
|
|
6
6
|
import type { AssistantMessage, OpenCodeTuiApi, SubAgentSummary } from "./types.ts"
|
|
7
7
|
import {
|
|
@@ -22,7 +22,9 @@ export function CacheHitSidebarHost(props: {
|
|
|
22
22
|
theme: Record<string, unknown>
|
|
23
23
|
display: DisplayConfig
|
|
24
24
|
timeline: TimelineConfig
|
|
25
|
+
cacheTTL: CacheTTLConfig
|
|
25
26
|
formatCost: (amount: number) => string
|
|
27
|
+
formatRate: (perMillion: number) => string
|
|
26
28
|
api: OpenCodeTuiApi
|
|
27
29
|
}) {
|
|
28
30
|
const [refreshTick, setRefreshTick] = createSignal(0)
|
|
@@ -34,6 +36,7 @@ export function CacheHitSidebarHost(props: {
|
|
|
34
36
|
return loadPluginConfig()
|
|
35
37
|
})
|
|
36
38
|
const display = createMemo(() => runtimeConfig().display)
|
|
39
|
+
const cacheTTL = createMemo(() => runtimeConfig().cacheTTL)
|
|
37
40
|
|
|
38
41
|
const bumpRefresh = () => setRefreshTick((v) => v + 1)
|
|
39
42
|
|
|
@@ -112,10 +115,13 @@ export function CacheHitSidebarHost(props: {
|
|
|
112
115
|
sessionId={() => props.sessionId}
|
|
113
116
|
theme={props.theme}
|
|
114
117
|
display={display()}
|
|
118
|
+
cacheTTL={cacheTTL()}
|
|
115
119
|
messages={mainMessages}
|
|
116
120
|
main={mainSnap}
|
|
117
121
|
subAgents={subAgentList}
|
|
122
|
+
providers={() => props.api.state.provider ?? []}
|
|
118
123
|
formatCost={props.formatCost}
|
|
124
|
+
formatRate={props.formatRate}
|
|
119
125
|
/>
|
|
120
126
|
)
|
|
121
127
|
}
|
package/src/stats.ts
CHANGED
|
@@ -11,11 +11,12 @@ export function mainSessionHasStats(main: SessionSnapshot): boolean {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function emptySessionSnapshot(): SessionSnapshot {
|
|
14
|
-
return { model: "", input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }
|
|
14
|
+
return { model: "", providerID: "", input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function aggregateSessionFromMessages(messages: readonly AssistantMessage[]): SessionSnapshot {
|
|
18
18
|
let model = "",
|
|
19
|
+
providerID = "",
|
|
19
20
|
input = 0,
|
|
20
21
|
output = 0,
|
|
21
22
|
reasoning = 0,
|
|
@@ -32,13 +33,16 @@ export function aggregateSessionFromMessages(messages: readonly AssistantMessage
|
|
|
32
33
|
cacheWrite += t.cache?.write ?? 0
|
|
33
34
|
cost += msg.cost ?? 0
|
|
34
35
|
if (msg.modelID) model = msg.modelID
|
|
36
|
+
if (msg.providerID) providerID = msg.providerID
|
|
35
37
|
}
|
|
36
|
-
return { model, input, output, reasoning, cacheRead, cacheWrite, cost }
|
|
38
|
+
return { model, providerID, input, output, reasoning, cacheRead, cacheWrite, cost }
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
export function toSubAgentSummary(id: string, snap: SessionSnapshot): SubAgentSummary {
|
|
40
42
|
return {
|
|
41
43
|
id,
|
|
44
|
+
model: snap.model,
|
|
45
|
+
providerID: snap.providerID,
|
|
42
46
|
cost: snap.cost,
|
|
43
47
|
input: snap.input,
|
|
44
48
|
output: snap.output,
|
|
@@ -66,19 +70,6 @@ export function cacheHitRatio(cacheRead: number, input: number): number {
|
|
|
66
70
|
return denom > 0 ? cacheRead / denom : 0
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
export function combinedCacheHitRatio(
|
|
70
|
-
main: SessionSnapshot,
|
|
71
|
-
subs: readonly Pick<SubAgentSummary, "cacheRead" | "input">[],
|
|
72
|
-
): number {
|
|
73
|
-
let cacheRead = main.cacheRead
|
|
74
|
-
let input = main.input
|
|
75
|
-
for (const s of subs) {
|
|
76
|
-
cacheRead += s.cacheRead
|
|
77
|
-
input += s.input
|
|
78
|
-
}
|
|
79
|
-
return cacheHitRatio(cacheRead, input)
|
|
80
|
-
}
|
|
81
|
-
|
|
82
73
|
export function subAgentHasStats(snap: SessionSnapshot): boolean {
|
|
83
74
|
return (
|
|
84
75
|
snap.cost > 0 ||
|
package/src/timeline/records.ts
CHANGED
|
@@ -3,6 +3,21 @@ import { perMessageHitPercent } from "../stats.ts"
|
|
|
3
3
|
import type { AssistantMessage } from "../types.ts"
|
|
4
4
|
import type { LlmCallRecord } from "./types.ts"
|
|
5
5
|
|
|
6
|
+
/** Convert milliseconds timestamp to ISO 8601 with local timezone offset. */
|
|
7
|
+
export function msToISOString(ms: number): string {
|
|
8
|
+
const d = new Date(ms)
|
|
9
|
+
const off = -d.getTimezoneOffset()
|
|
10
|
+
const sign = off >= 0 ? "+" : "-"
|
|
11
|
+
const hh = String(Math.floor(Math.abs(off) / 60)).padStart(2, "0")
|
|
12
|
+
const mm = String(Math.abs(off) % 60).padStart(2, "0")
|
|
13
|
+
const pad = (n: number, len = 2) => String(n).padStart(len, "0")
|
|
14
|
+
return (
|
|
15
|
+
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
16
|
+
`T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
|
|
17
|
+
`.${pad(d.getMilliseconds(), 3)}${sign}${hh}:${mm}`
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
6
21
|
export function messageKeyFor(msg: AssistantMessage, sessionId: string): string {
|
|
7
22
|
const id = msg.id ?? msg.messageID
|
|
8
23
|
if (typeof id === "string" && id.length > 0) return `${sessionId}:${id}`
|
|
@@ -11,7 +26,15 @@ export function messageKeyFor(msg: AssistantMessage, sessionId: string): string
|
|
|
11
26
|
}
|
|
12
27
|
|
|
13
28
|
export function sortKeyForRecord(r: LlmCallRecord): number {
|
|
14
|
-
|
|
29
|
+
const ts = r.completedAt ?? r.created
|
|
30
|
+
return new Date(ts).getTime()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function mergeAndSortRecords(chunks: readonly LlmCallRecord[][]): LlmCallRecord[] {
|
|
34
|
+
const all = chunks.flat()
|
|
35
|
+
const keyed = all.map(r => ({ r, k: sortKeyForRecord(r) }))
|
|
36
|
+
keyed.sort((a, b) => a.k - b.k)
|
|
37
|
+
return keyed.map(x => x.r)
|
|
15
38
|
}
|
|
16
39
|
|
|
17
40
|
export function assistantMessageToRecord(
|
|
@@ -28,14 +51,14 @@ export function assistantMessageToRecord(
|
|
|
28
51
|
const skippedForHit = msg.summary === true
|
|
29
52
|
return {
|
|
30
53
|
schema: 1,
|
|
31
|
-
recordedAt,
|
|
54
|
+
recordedAt: msToISOString(recordedAt),
|
|
32
55
|
sessionId,
|
|
33
56
|
rootSessionId,
|
|
34
57
|
scope,
|
|
35
58
|
messageKey: messageKeyFor(msg, sessionId),
|
|
36
59
|
modelId: msg.modelID ?? "",
|
|
37
|
-
created: timing.created,
|
|
38
|
-
completedAt: timing.completedAt,
|
|
60
|
+
created: msToISOString(timing.created),
|
|
61
|
+
completedAt: timing.completedAt !== undefined ? msToISOString(timing.completedAt) : undefined,
|
|
39
62
|
durationMs: timing.durationMs,
|
|
40
63
|
isComplete: timing.isComplete,
|
|
41
64
|
input: t.input ?? 0,
|
|
@@ -67,7 +90,3 @@ export function buildCallRecords(
|
|
|
67
90
|
return out
|
|
68
91
|
}
|
|
69
92
|
|
|
70
|
-
export function mergeAndSortRecords(chunks: readonly LlmCallRecord[][]): LlmCallRecord[] {
|
|
71
|
-
const all = chunks.flat()
|
|
72
|
-
return all.sort((a, b) => sortKeyForRecord(a) - sortKeyForRecord(b))
|
|
73
|
-
}
|
package/src/timeline/types.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/** Single LLM call row (one JSONL line). */
|
|
2
2
|
export type LlmCallRecord = {
|
|
3
3
|
schema: 1
|
|
4
|
-
recordedAt:
|
|
4
|
+
recordedAt: string
|
|
5
5
|
sessionId: string
|
|
6
6
|
rootSessionId: string
|
|
7
7
|
scope: "main" | "child"
|
|
8
8
|
messageKey: string
|
|
9
9
|
modelId: string
|
|
10
|
-
created:
|
|
11
|
-
completedAt?:
|
|
10
|
+
created: string
|
|
11
|
+
completedAt?: string
|
|
12
12
|
durationMs?: number
|
|
13
13
|
isComplete: boolean
|
|
14
14
|
input: number
|
package/src/timeline/writer.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { appendFile, mkdir, readdir, stat, unlink } from "node:fs/promises"
|
|
2
|
+
import { homedir } from "node:os"
|
|
2
3
|
import { dirname, join } from "node:path"
|
|
3
4
|
import type { TimelineConfig } from "../plugin-config.ts"
|
|
4
|
-
import { PLUGIN_ROOT } from "../load-config.ts"
|
|
5
5
|
import type { LlmCallRecord } from "./types.ts"
|
|
6
6
|
import { rotateFileBySize, trimFileToMaxLines } from "./rotation.ts"
|
|
7
7
|
|
|
8
|
-
export const DEFAULT_TIMELINE_DIR = join(
|
|
8
|
+
export const DEFAULT_TIMELINE_DIR = join(homedir(), ".local", "share", "opencode", "logs", "cache-hit")
|
|
9
9
|
export const TIMELINE_FILE_PREFIX = "timeline"
|
|
10
10
|
|
|
11
11
|
export type TimelineWriteOptions = Pick<
|
|
@@ -14,8 +14,9 @@ export type TimelineWriteOptions = Pick<
|
|
|
14
14
|
>
|
|
15
15
|
|
|
16
16
|
export function resolveTimelineDir(config: TimelineConfig): string {
|
|
17
|
-
const raw = config.dir
|
|
18
|
-
|
|
17
|
+
const raw = (config.dir ?? "").trim()
|
|
18
|
+
if (!raw) return DEFAULT_TIMELINE_DIR
|
|
19
|
+
return raw.startsWith("~/") ? join(homedir(), raw.slice(2)) : raw
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
/** Local calendar day `YYYY-MM-DD` for daily log files. */
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type SessionSnapshot = {
|
|
2
2
|
model: string
|
|
3
|
+
providerID: string
|
|
3
4
|
input: number
|
|
4
5
|
output: number
|
|
5
6
|
reasoning: number
|
|
@@ -10,6 +11,8 @@ export type SessionSnapshot = {
|
|
|
10
11
|
|
|
11
12
|
export type SubAgentSummary = {
|
|
12
13
|
id: string
|
|
14
|
+
model: string
|
|
15
|
+
providerID: string
|
|
13
16
|
cost: number
|
|
14
17
|
input: number
|
|
15
18
|
output: number
|
|
@@ -23,6 +26,7 @@ export type AssistantMessage = {
|
|
|
23
26
|
id?: string
|
|
24
27
|
messageID?: string
|
|
25
28
|
modelID?: string
|
|
29
|
+
providerID?: string
|
|
26
30
|
cost?: number
|
|
27
31
|
/** OpenCode SDK: true = summary/compaction message, not a full LLM pricing turn */
|
|
28
32
|
summary?: boolean
|
|
@@ -38,9 +42,21 @@ export type AssistantMessage = {
|
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
export type ModelCost = {
|
|
46
|
+
input: number
|
|
47
|
+
output: number
|
|
48
|
+
cache: { read: number; write: number }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type ProviderInfo = {
|
|
52
|
+
id: string
|
|
53
|
+
models: { [key: string]: { cost: ModelCost } }
|
|
54
|
+
}
|
|
55
|
+
|
|
41
56
|
export type OpenCodeTuiApi = {
|
|
42
57
|
state: {
|
|
43
58
|
path: { directory: string }
|
|
59
|
+
provider: ReadonlyArray<ProviderInfo>
|
|
44
60
|
session: {
|
|
45
61
|
messages: (id: string) => unknown[] | undefined
|
|
46
62
|
get: (id: string) => { parentID?: string } | undefined
|