opencode-tweaks 0.6.1 → 0.7.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 +17 -0
- package/package.json +1 -1
- package/src/tui.tsx +6 -0
- package/src/tweaks/model-panel.tsx +203 -0
- package/src/tweaks/model.ts +63 -0
- package/src/tweaks/registry.ts +2 -1
- package/src/usage/cost.ts +9 -0
- package/src/usage/price-window.ts +14 -4
package/README.md
CHANGED
|
@@ -100,6 +100,7 @@ Resetting a setting drops the local value, so the option or default applies agai
|
|
|
100
100
|
| `quota` | Provider quota, reset times, and a daily spending budget. | On |
|
|
101
101
|
| `tokens` | Token usage per turn, plus session totals at the bottom. | On |
|
|
102
102
|
| `cost` | Cost per turn, plus session totals at the bottom. | On |
|
|
103
|
+
| `model` | Current model list price and peak state in the sidebar. | On |
|
|
103
104
|
|
|
104
105
|
### Quota
|
|
105
106
|
|
|
@@ -194,6 +195,20 @@ per turn, and the estimate follows the API rates instead. Costs use the raw coun
|
|
|
194
195
|
|
|
195
196
|
The cost settings used to live in the Tokens entry. A choice saved there before the split keeps applying.
|
|
196
197
|
|
|
198
|
+
### Model
|
|
199
|
+
|
|
200
|
+
Model shows the model the session is using and its list price in the sidebar, under the quota. The name, provider,
|
|
201
|
+
and reasoning variant come from the session, so the card follows `/models`. Click the `Model` header to collapse or
|
|
202
|
+
expand the card.
|
|
203
|
+
|
|
204
|
+
`Input price`, `Output price`, and `Cache read price` choose the rates, all in dollars per million tokens. The card
|
|
205
|
+
uses the catalog price that applies to the session's context, so a long-context tier shows the higher rate with its
|
|
206
|
+
threshold (`>512K`).
|
|
207
|
+
|
|
208
|
+
`Peak state` adds the provider's live peak marker for schedules the plugin knows, such as DeepSeek and OpenCode Go:
|
|
209
|
+
`peak ×2 now` during the higher window and `off-peak` otherwise. The badge on the turn cost still records the window
|
|
210
|
+
each round started in.
|
|
211
|
+
|
|
197
212
|
## Development
|
|
198
213
|
|
|
199
214
|
```sh
|
|
@@ -232,6 +247,8 @@ src/
|
|
|
232
247
|
quota-panel.tsx sidebar quota panel
|
|
233
248
|
tokens.ts token tweak definition
|
|
234
249
|
cost.ts cost tweak definition
|
|
250
|
+
model.ts model tweak definition
|
|
251
|
+
model-panel.tsx sidebar model price card
|
|
235
252
|
usage-slots.tsx inline and status line figures
|
|
236
253
|
usage-inject.ts splice into the host's lines
|
|
237
254
|
```
|
package/package.json
CHANGED
package/src/tui.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Plugin } from "@opencode/plugin/tui"
|
|
2
2
|
import { openTweaksMenu } from "./tweaks/menu"
|
|
3
|
+
import { ModelPanel } from "./tweaks/model-panel"
|
|
3
4
|
import { QuotaPanel } from "./tweaks/quota-panel"
|
|
4
5
|
import { createSettingsStore } from "./tweaks/settings"
|
|
5
6
|
import { createUsageInjector } from "./tweaks/usage-inject"
|
|
@@ -17,6 +18,11 @@ export default Plugin.define({
|
|
|
17
18
|
render: () => <QuotaPanel context={context} store={store} />,
|
|
18
19
|
})
|
|
19
20
|
|
|
21
|
+
context.ui.slot({
|
|
22
|
+
append: "sidebar.content",
|
|
23
|
+
render: (input) => <ModelPanel context={context} input={input} store={store} />,
|
|
24
|
+
})
|
|
25
|
+
|
|
20
26
|
context.ui.slot({
|
|
21
27
|
append: "session.composer.top",
|
|
22
28
|
render: (input) => (
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { Context, SlotMap } from "@opencode/plugin/tui/context"
|
|
2
|
+
import { TextAttributes } from "@opentui/core"
|
|
3
|
+
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
|
4
|
+
import { formatRate, priceFor } from "../usage/cost"
|
|
5
|
+
import { hasPriceSchedule, priceWindowFor } from "../usage/price-window"
|
|
6
|
+
import { formatTokens } from "../usage/tokens"
|
|
7
|
+
import { modelTweak } from "./model"
|
|
8
|
+
import type { SettingsStore } from "./settings"
|
|
9
|
+
import type { TweakSetting } from "./types"
|
|
10
|
+
|
|
11
|
+
function setting(id: string): TweakSetting {
|
|
12
|
+
const found = modelTweak.settings.find((item) => item.id === id)
|
|
13
|
+
if (!found) throw new Error(`Model setting "${id}" is missing`)
|
|
14
|
+
return found
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ENABLED = setting("enabled")
|
|
18
|
+
const INPUT = setting("input")
|
|
19
|
+
const OUTPUT = setting("output")
|
|
20
|
+
const CACHE = setting("cache")
|
|
21
|
+
const VARIANT = setting("variant")
|
|
22
|
+
const PEAK = setting("peak")
|
|
23
|
+
|
|
24
|
+
/** The ref stored on a session or returned by the composer. */
|
|
25
|
+
interface ModelRef {
|
|
26
|
+
readonly providerID: string
|
|
27
|
+
readonly id: string
|
|
28
|
+
readonly variant?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The model card is a companion to the cost figures: it names the model and
|
|
33
|
+
* lists its catalog rates, while the cost tweak counts what the session spent.
|
|
34
|
+
*/
|
|
35
|
+
export function ModelPanel(props: {
|
|
36
|
+
readonly context: Context
|
|
37
|
+
readonly input: SlotMap["sidebar.content"]
|
|
38
|
+
readonly store: SettingsStore
|
|
39
|
+
}) {
|
|
40
|
+
const enabled = () => props.store.read(modelTweak, ENABLED).value === true
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Show when={enabled()}>
|
|
44
|
+
<ModelBody context={props.context} input={props.input} store={props.store} />
|
|
45
|
+
</Show>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ModelBody(props: {
|
|
50
|
+
readonly context: Context
|
|
51
|
+
readonly input: SlotMap["sidebar.content"]
|
|
52
|
+
readonly store: SettingsStore
|
|
53
|
+
}) {
|
|
54
|
+
const theme = () => props.context.theme
|
|
55
|
+
const [now, setNow] = createSignal(Date.now())
|
|
56
|
+
const [collapsed, setCollapsed] = createSignal(false)
|
|
57
|
+
|
|
58
|
+
const inputOn = () => props.store.read(modelTweak, INPUT).value === true
|
|
59
|
+
const outputOn = () => props.store.read(modelTweak, OUTPUT).value === true
|
|
60
|
+
const cacheOn = () => props.store.read(modelTweak, CACHE).value === true
|
|
61
|
+
const variantOn = () => props.store.read(modelTweak, VARIANT).value === true
|
|
62
|
+
const peakOn = () => props.store.read(modelTweak, PEAK).value === true
|
|
63
|
+
|
|
64
|
+
/** The session's model, or the composer's while the session has none yet. */
|
|
65
|
+
const model = createMemo<ModelRef | undefined>(() => {
|
|
66
|
+
const session = props.context.data.session.get(props.input.sessionID)
|
|
67
|
+
if (session?.model) return session.model
|
|
68
|
+
|
|
69
|
+
const current = props.context.ui.model.current()
|
|
70
|
+
return current
|
|
71
|
+
? { providerID: current.providerID, id: current.modelID, variant: current.variant }
|
|
72
|
+
: undefined
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/** The catalog entry for that model, with what the panel needs to show it. */
|
|
76
|
+
const entry = createMemo(() => {
|
|
77
|
+
const ref = model()
|
|
78
|
+
if (!ref) return undefined
|
|
79
|
+
|
|
80
|
+
const found = (props.context.data.location.model.list() ?? []).find(
|
|
81
|
+
(candidate) =>
|
|
82
|
+
candidate.providerID === ref.providerID &&
|
|
83
|
+
(candidate.modelID === ref.id || candidate.id === ref.id),
|
|
84
|
+
)
|
|
85
|
+
const provider = (props.context.data.location.provider.list() ?? []).find(
|
|
86
|
+
(candidate) => candidate.id === ref.providerID,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
ref,
|
|
91
|
+
name: found?.name ?? ref.id,
|
|
92
|
+
provider: provider?.name,
|
|
93
|
+
costs: found?.cost ?? [],
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
/** The context the session has read so far, which picks a long-context tier. */
|
|
98
|
+
const contextSize = createMemo(() => {
|
|
99
|
+
const tokens = props.context.data.session.get(props.input.sessionID)?.tokens
|
|
100
|
+
return tokens ? tokens.input + tokens.cache.read : 0
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const price = createMemo(() => {
|
|
104
|
+
const costs = entry()?.costs
|
|
105
|
+
return costs?.length ? priceFor(costs, contextSize()) : undefined
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const rates = createMemo(() => {
|
|
109
|
+
const value = price()
|
|
110
|
+
if (!value) return []
|
|
111
|
+
|
|
112
|
+
const rows: { label: string; value: string }[] = []
|
|
113
|
+
if (inputOn()) rows.push({ label: "in", value: formatRate(value.input) })
|
|
114
|
+
if (outputOn()) rows.push({ label: "out", value: formatRate(value.output) })
|
|
115
|
+
if (cacheOn()) rows.push({ label: "cache", value: formatRate(value.cache.read) })
|
|
116
|
+
if (rows.length > 0 && value.tier) {
|
|
117
|
+
rows.push({ label: "from", value: `>${formatTokens(value.tier.size, "compact")}` })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return rows
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const variant = () => (variantOn() ? entry()?.ref.variant : undefined)
|
|
124
|
+
|
|
125
|
+
const title = () => {
|
|
126
|
+
const name = entry()?.name
|
|
127
|
+
const selected = variant()
|
|
128
|
+
return selected ? `${name} · ${selected}` : name
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const scheduled = () => {
|
|
132
|
+
const ref = entry()?.ref
|
|
133
|
+
return ref ? hasPriceSchedule(ref) : false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const peak = () => {
|
|
137
|
+
const ref = entry()?.ref
|
|
138
|
+
if (!ref || !peakOn() || !scheduled()) return undefined
|
|
139
|
+
return priceWindowFor(ref, new Date(now()))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const stateValue = () => {
|
|
143
|
+
if (!peakOn() || !scheduled()) return undefined
|
|
144
|
+
const value = peak()
|
|
145
|
+
return value ? `${value.label} ×${value.multiplier} now` : "off-peak"
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The live peak marker follows the clock, so refresh it while the card can
|
|
149
|
+
// show one. Models without a schedule and a hidden peak row need no timer.
|
|
150
|
+
createEffect(() => {
|
|
151
|
+
if (!peakOn() || !scheduled()) return
|
|
152
|
+
|
|
153
|
+
setNow(Date.now())
|
|
154
|
+
const timer = setInterval(() => setNow(Date.now()), 30_000)
|
|
155
|
+
onCleanup(() => clearInterval(timer))
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<box flexDirection="column" marginTop={1}>
|
|
160
|
+
<box
|
|
161
|
+
flexDirection="row"
|
|
162
|
+
justifyContent="space-between"
|
|
163
|
+
onMouseUp={() => setCollapsed((value) => !value)}
|
|
164
|
+
>
|
|
165
|
+
<text attributes={TextAttributes.BOLD} fg={theme().text.base}>
|
|
166
|
+
{collapsed() ? "▶" : "▼"} Model
|
|
167
|
+
</text>
|
|
168
|
+
<Show when={entry()?.provider}>
|
|
169
|
+
<text fg={theme().text.muted} wrapMode="none">
|
|
170
|
+
{entry()?.provider}
|
|
171
|
+
</text>
|
|
172
|
+
</Show>
|
|
173
|
+
</box>
|
|
174
|
+
<Show when={!collapsed()}>
|
|
175
|
+
<Show when={entry()} fallback={<text fg={theme().text.muted}>No model selected</text>}>
|
|
176
|
+
<text fg={theme().text.base} wrapMode="word">
|
|
177
|
+
{title()}
|
|
178
|
+
</text>
|
|
179
|
+
<For each={rates()}>
|
|
180
|
+
{(row) => (
|
|
181
|
+
<box flexDirection="row" gap={1}>
|
|
182
|
+
<text flexShrink={0} fg={theme().text.muted}>
|
|
183
|
+
{row.label.padEnd(5)}
|
|
184
|
+
</text>
|
|
185
|
+
<text fg={theme().text.base}>{row.value}</text>
|
|
186
|
+
</box>
|
|
187
|
+
)}
|
|
188
|
+
</For>
|
|
189
|
+
<Show when={stateValue()}>
|
|
190
|
+
<box flexDirection="row" gap={1}>
|
|
191
|
+
<text flexShrink={0} fg={theme().text.muted}>
|
|
192
|
+
{"state".padEnd(5)}
|
|
193
|
+
</text>
|
|
194
|
+
<text fg={peak() ? theme().text.feedback.warning.base : theme().text.muted}>
|
|
195
|
+
{stateValue()}
|
|
196
|
+
</text>
|
|
197
|
+
</box>
|
|
198
|
+
</Show>
|
|
199
|
+
</Show>
|
|
200
|
+
</Show>
|
|
201
|
+
</box>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { defineTweak } from "./types"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The sidebar card reads the same catalog as the cost figures, but stays its
|
|
5
|
+
* own tweak: it describes the model, while the cost tweak counts what it spent.
|
|
6
|
+
* Every rate is optional so the card fits a narrow sidebar.
|
|
7
|
+
*/
|
|
8
|
+
export const modelTweak = defineTweak({
|
|
9
|
+
id: "model",
|
|
10
|
+
title: "Model",
|
|
11
|
+
description: "Show the current model's list price and peak state in the sidebar.",
|
|
12
|
+
category: "Session",
|
|
13
|
+
settings: [
|
|
14
|
+
{
|
|
15
|
+
id: "enabled",
|
|
16
|
+
title: "Enabled",
|
|
17
|
+
description: "Show the model card in the sidebar.",
|
|
18
|
+
category: "General",
|
|
19
|
+
kind: "boolean",
|
|
20
|
+
default: true,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "input",
|
|
24
|
+
title: "Input price",
|
|
25
|
+
description: "Show the input rate per million tokens.",
|
|
26
|
+
category: "Rates",
|
|
27
|
+
kind: "boolean",
|
|
28
|
+
default: true,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "output",
|
|
32
|
+
title: "Output price",
|
|
33
|
+
description: "Show the output rate per million tokens.",
|
|
34
|
+
category: "Rates",
|
|
35
|
+
kind: "boolean",
|
|
36
|
+
default: true,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "cache",
|
|
40
|
+
title: "Cache read price",
|
|
41
|
+
description: "Show the cache read rate per million tokens.",
|
|
42
|
+
category: "Rates",
|
|
43
|
+
kind: "boolean",
|
|
44
|
+
default: true,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "variant",
|
|
48
|
+
title: "Variant",
|
|
49
|
+
description: "Show the reasoning variant selected for the model.",
|
|
50
|
+
category: "Display",
|
|
51
|
+
kind: "boolean",
|
|
52
|
+
default: true,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "peak",
|
|
56
|
+
title: "Peak state",
|
|
57
|
+
description: "Show whether the model is billed above its catalog price right now.",
|
|
58
|
+
category: "Display",
|
|
59
|
+
kind: "boolean",
|
|
60
|
+
default: true,
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
})
|
package/src/tweaks/registry.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { costTweak } from "./cost"
|
|
2
|
+
import { modelTweak } from "./model"
|
|
2
3
|
import { quotaTweak } from "./quota"
|
|
3
4
|
import { tokensTweak } from "./tokens"
|
|
4
5
|
import type { Tweak } from "./types"
|
|
5
6
|
|
|
6
7
|
/** Every tweak shown by `/tweaks`. Add new definitions here. */
|
|
7
|
-
export const tweaks: readonly Tweak[] = [quotaTweak, tokensTweak, costTweak]
|
|
8
|
+
export const tweaks: readonly Tweak[] = [quotaTweak, tokensTweak, costTweak, modelTweak]
|
|
8
9
|
|
|
9
10
|
export function findTweak(id: string): Tweak | undefined {
|
|
10
11
|
return tweaks.find((tweak) => tweak.id === id)
|
package/src/usage/cost.ts
CHANGED
|
@@ -85,6 +85,15 @@ export function isCostDigits(value: unknown): value is CostDigits {
|
|
|
85
85
|
return value === "auto" || value === 2 || value === 3 || value === 4 || value === 5 || value === 6
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* A list rate per million tokens. Normal rates keep two decimals; sub-cent
|
|
90
|
+
* rates show up to six and drop trailing zeros, so `$0.003` stays readable.
|
|
91
|
+
*/
|
|
92
|
+
export function formatRate(value: number): string {
|
|
93
|
+
if (value >= 0.01) return `$${value.toFixed(2)}`
|
|
94
|
+
return `$${value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "")}`
|
|
95
|
+
}
|
|
96
|
+
|
|
88
97
|
/** Normal amounts keep two decimals; smaller ones grow until a digit shows. */
|
|
89
98
|
function autoDigits(cost: number): number {
|
|
90
99
|
if (cost === 0 || cost >= 0.01) return 2
|
|
@@ -107,6 +107,19 @@ function utcMinutes(date: Date): number {
|
|
|
107
107
|
return date.getUTCHours() * 60 + date.getUTCMinutes()
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/** The schedule a model follows, when its provider publishes one. */
|
|
111
|
+
function scheduleFor(model: PriceModel): PriceSchedule | undefined {
|
|
112
|
+
return schedules.find(
|
|
113
|
+
(candidate) =>
|
|
114
|
+
candidate.providers.includes(model.providerID) && model.id.startsWith(candidate.modelPrefix),
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Whether the model follows a published peak schedule, even off-peak. */
|
|
119
|
+
export function hasPriceSchedule(model: PriceModel | undefined): boolean {
|
|
120
|
+
return model ? scheduleFor(model) !== undefined : false
|
|
121
|
+
}
|
|
122
|
+
|
|
110
123
|
/**
|
|
111
124
|
* The higher price that applies to a model at `at`, or `undefined` when the
|
|
112
125
|
* catalog price is the one being charged. The check is deliberately narrow:
|
|
@@ -115,10 +128,7 @@ function utcMinutes(date: Date): number {
|
|
|
115
128
|
export function priceWindowFor(model: PriceModel | undefined, at: Date): PriceWindow | undefined {
|
|
116
129
|
if (!model) return undefined
|
|
117
130
|
|
|
118
|
-
const schedule =
|
|
119
|
-
(candidate) =>
|
|
120
|
-
candidate.providers.includes(model.providerID) && model.id.startsWith(candidate.modelPrefix),
|
|
121
|
-
)
|
|
131
|
+
const schedule = scheduleFor(model)
|
|
122
132
|
if (!schedule) return undefined
|
|
123
133
|
|
|
124
134
|
if (!schedule.peakDays.includes(weekday(at))) return undefined
|