opencode-tweaks 0.7.2 → 0.9.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/README.md +18 -8
- package/package.json +1 -1
- package/src/tweaks/model-panel.tsx +123 -87
- package/src/tweaks/model.ts +27 -7
- package/src/usage/catalog.ts +41 -0
package/README.md
CHANGED
|
@@ -197,17 +197,26 @@ The cost settings used to live in the Tokens entry. A choice saved there before
|
|
|
197
197
|
|
|
198
198
|
### Model
|
|
199
199
|
|
|
200
|
-
Model shows the model
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
Model shows the current model's list price in the sidebar, under the quota. The model name, provider, and variant
|
|
201
|
+
already sit on the bottom bar, so the card only carries the rates and the peak state. The rates follow the prompt's
|
|
202
|
+
selected model, so picking another one in `/models` updates them at once, before the next turn. Click the `Model`
|
|
203
|
+
header to collapse or expand it.
|
|
203
204
|
|
|
204
205
|
`Input price`, `Output price`, and `Cache read price` choose the rates, all in dollars per million tokens. The card
|
|
205
|
-
|
|
206
|
-
|
|
206
|
+
draws them as a price list with the labels on the left and the values in the theme accent. `Price alignment` picks
|
|
207
|
+
whether the prices start on one shared column (`left`) or sit against the right edge (`right`). It uses the catalog
|
|
208
|
+
price that applies to the session's context, so a long-context tier shows the higher rate with its threshold
|
|
209
|
+
(`>512K`).
|
|
207
210
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
+
A model that bills nothing, such as a free promo, follows `Free models`. `List price` shows the rates of the paid
|
|
212
|
+
catalog entry it borrows. `Free label` shows a single `Free` line instead. `Hide card` removes the card until a paid
|
|
213
|
+
model is selected.
|
|
214
|
+
|
|
215
|
+
`Peak state` puts the price multiplier on the right of the card header for schedules the plugin knows, such as
|
|
216
|
+
DeepSeek and OpenCode Go: `×2` in the warning colour during the higher window and `×1` in the normal text colour off
|
|
217
|
+
it. A model without a schedule shows nothing. The marker stays visible when the card is collapsed. `Peak only` hides
|
|
218
|
+
`×1`, so the header stays empty at the base price. The badge on the turn cost still records the window each round
|
|
219
|
+
started in.
|
|
211
220
|
|
|
212
221
|
## Development
|
|
213
222
|
|
|
@@ -234,6 +243,7 @@ src/
|
|
|
234
243
|
messages.ts message shape and turn selection
|
|
235
244
|
tokens.ts token sums and formatting
|
|
236
245
|
cost.ts list prices, estimates, and cost formatting
|
|
246
|
+
catalog.ts catalog entries and paid sibling lookup
|
|
237
247
|
price-window.ts provider peak hours and holidays
|
|
238
248
|
tui.tsx terminal entry: commands and slots
|
|
239
249
|
tweaks/
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Context, SlotMap } from "@opencode/plugin/tui/context"
|
|
2
2
|
import { TextAttributes } from "@opentui/core"
|
|
3
3
|
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
|
4
|
+
import { type CatalogModel, paidSibling } from "../usage/catalog"
|
|
4
5
|
import { formatRate, priceFor } from "../usage/cost"
|
|
5
6
|
import { hasPriceSchedule, priceWindowFor } from "../usage/price-window"
|
|
6
7
|
import { formatTokens } from "../usage/tokens"
|
|
@@ -18,19 +19,21 @@ const ENABLED = setting("enabled")
|
|
|
18
19
|
const INPUT = setting("input")
|
|
19
20
|
const OUTPUT = setting("output")
|
|
20
21
|
const CACHE = setting("cache")
|
|
21
|
-
const VARIANT = setting("variant")
|
|
22
22
|
const PEAK = setting("peak")
|
|
23
|
+
const PEAK_ONLY = setting("peakOnly")
|
|
24
|
+
const ALIGN = setting("align")
|
|
25
|
+
const FREE = setting("free")
|
|
23
26
|
|
|
24
27
|
/** The ref stored on a session or returned by the composer. */
|
|
25
28
|
interface ModelRef {
|
|
26
29
|
readonly providerID: string
|
|
27
30
|
readonly id: string
|
|
28
|
-
readonly variant?: string
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
|
-
* The model card
|
|
33
|
-
*
|
|
34
|
+
* The model card sits under the quota and carries what the bottom bar does
|
|
35
|
+
* not: the current model's list price and its provider's peak state. The
|
|
36
|
+
* model name and variant already live on that bar, so the card stays compact.
|
|
34
37
|
*/
|
|
35
38
|
export function ModelPanel(props: {
|
|
36
39
|
readonly context: Context
|
|
@@ -58,40 +61,35 @@ function ModelBody(props: {
|
|
|
58
61
|
const inputOn = () => props.store.read(modelTweak, INPUT).value === true
|
|
59
62
|
const outputOn = () => props.store.read(modelTweak, OUTPUT).value === true
|
|
60
63
|
const cacheOn = () => props.store.read(modelTweak, CACHE).value === true
|
|
61
|
-
const variantOn = () => props.store.read(modelTweak, VARIANT).value === true
|
|
62
64
|
const peakOn = () => props.store.read(modelTweak, PEAK).value === true
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
const peakOnly = () => props.store.read(modelTweak, PEAK_ONLY).value === true
|
|
66
|
+
const alignRight = () => props.store.read(modelTweak, ALIGN).value === "right"
|
|
67
|
+
const freeMode = () => String(props.store.read(modelTweak, FREE).value)
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The model the next prompt would use, read from the prompt selection so a
|
|
71
|
+
* switch in `/models` shows its rates at once. The session only supplies a
|
|
72
|
+
* model for a transcript opened without a prompt of its own.
|
|
73
|
+
*/
|
|
65
74
|
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
75
|
const current = props.context.ui.model.current()
|
|
70
|
-
return current
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
if (current) return { providerID: current.providerID, id: current.modelID }
|
|
77
|
+
|
|
78
|
+
return props.context.data.session.get(props.input.sessionID)?.model
|
|
73
79
|
})
|
|
74
80
|
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
const catalog = () => props.context.data.location.model.list() ?? []
|
|
82
|
+
|
|
83
|
+
/** The catalog entry for that model, when the catalog knows it. */
|
|
84
|
+
const entry = createMemo<CatalogModel | undefined>(() => {
|
|
77
85
|
const ref = model()
|
|
78
86
|
if (!ref) return undefined
|
|
79
87
|
|
|
80
|
-
|
|
88
|
+
return catalog().find(
|
|
81
89
|
(candidate) =>
|
|
82
90
|
candidate.providerID === ref.providerID &&
|
|
83
91
|
(candidate.modelID === ref.id || candidate.id === ref.id),
|
|
84
92
|
)
|
|
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
93
|
})
|
|
96
94
|
|
|
97
95
|
/** The context the session has read so far, which picks a long-context tier. */
|
|
@@ -101,52 +99,80 @@ function ModelBody(props: {
|
|
|
101
99
|
})
|
|
102
100
|
|
|
103
101
|
const price = createMemo(() => {
|
|
104
|
-
const
|
|
105
|
-
return
|
|
102
|
+
const list = entry()?.cost
|
|
103
|
+
return list && list.length > 0 ? priceFor(list, contextSize()) : undefined
|
|
106
104
|
})
|
|
107
105
|
|
|
108
|
-
|
|
106
|
+
/** A free model bills zero on every rate the card can show. */
|
|
107
|
+
const freeModel = createMemo(() => {
|
|
109
108
|
const value = price()
|
|
109
|
+
return value ? value.input === 0 && value.output === 0 && value.cache.read === 0 : false
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
/** The paid list price of a free promo, when the catalog has its sibling. */
|
|
113
|
+
const reference = createMemo(() => {
|
|
114
|
+
if (!freeModel() || freeMode() !== "reference") return undefined
|
|
115
|
+
const selected = entry()
|
|
116
|
+
if (!selected) return undefined
|
|
117
|
+
|
|
118
|
+
const sibling = paidSibling(catalog(), selected)
|
|
119
|
+
return sibling ? priceFor(sibling.cost, contextSize()) : undefined
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
/** The rates on screen: the selected model's, or the reference it borrows. */
|
|
123
|
+
const shown = createMemo(() => reference() ?? (freeModel() ? undefined : price()))
|
|
124
|
+
|
|
125
|
+
const rates = createMemo(() => {
|
|
126
|
+
const value = shown()
|
|
110
127
|
if (!value) return []
|
|
111
128
|
|
|
112
|
-
const rows: { label: string; value: string }[] = []
|
|
113
|
-
if (inputOn()) rows.push({ label: "
|
|
114
|
-
if (outputOn()) rows.push({ label: "
|
|
115
|
-
if (cacheOn()) rows.push({ label: "
|
|
129
|
+
const rows: { label: string; value: string; muted?: boolean }[] = []
|
|
130
|
+
if (inputOn()) rows.push({ label: "Input", value: formatRate(value.input) })
|
|
131
|
+
if (outputOn()) rows.push({ label: "Output", value: formatRate(value.output) })
|
|
132
|
+
if (cacheOn()) rows.push({ label: "Cache", value: formatRate(value.cache.read) })
|
|
116
133
|
if (rows.length > 0 && value.tier) {
|
|
117
|
-
rows.push({
|
|
134
|
+
rows.push({
|
|
135
|
+
label: "From",
|
|
136
|
+
value: `>${formatTokens(value.tier.size, "compact")}`,
|
|
137
|
+
muted: true,
|
|
138
|
+
})
|
|
118
139
|
}
|
|
119
140
|
|
|
120
141
|
return rows
|
|
121
142
|
})
|
|
122
143
|
|
|
123
|
-
|
|
144
|
+
/** A free model with no reference price says so instead of drawing zeroes. */
|
|
145
|
+
const freeLabel = createMemo(() => {
|
|
146
|
+
if (!freeModel() || reference()) return false
|
|
147
|
+
return inputOn() || outputOn() || cacheOn()
|
|
148
|
+
})
|
|
124
149
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
150
|
+
/** `Hide card` removes the panel entirely while a free model is selected. */
|
|
151
|
+
const hidden = () => freeModel() && freeMode() === "hidden"
|
|
152
|
+
|
|
153
|
+
/** The widest label, so the prices start on one column. */
|
|
154
|
+
const labelWidth = createMemo(() =>
|
|
155
|
+
rates().reduce((width, row) => Math.max(width, row.label.length), 0),
|
|
156
|
+
)
|
|
130
157
|
|
|
131
158
|
const scheduled = () => {
|
|
132
|
-
const ref =
|
|
159
|
+
const ref = model()
|
|
133
160
|
return ref ? hasPriceSchedule(ref) : false
|
|
134
161
|
}
|
|
135
162
|
|
|
136
163
|
const peak = () => {
|
|
137
|
-
const ref =
|
|
164
|
+
const ref = model()
|
|
138
165
|
if (!ref || !peakOn() || !scheduled()) return undefined
|
|
139
166
|
return priceWindowFor(ref, new Date(now()))
|
|
140
167
|
}
|
|
141
168
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
169
|
+
/** The price multiplier now, `1` when the catalog price is the one charged. */
|
|
170
|
+
const multiplier = () => peak()?.multiplier ?? 1
|
|
171
|
+
|
|
172
|
+
const showMultiplier = () => peakOn() && scheduled() && (peak() !== undefined || !peakOnly())
|
|
147
173
|
|
|
148
174
|
// 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
|
|
175
|
+
// show one. Models without a schedule and a hidden peak state need no timer.
|
|
150
176
|
createEffect(() => {
|
|
151
177
|
if (!peakOn() || !scheduled()) return
|
|
152
178
|
|
|
@@ -156,48 +182,58 @@ function ModelBody(props: {
|
|
|
156
182
|
})
|
|
157
183
|
|
|
158
184
|
return (
|
|
159
|
-
<
|
|
160
|
-
<box
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
{
|
|
167
|
-
|
|
168
|
-
<Show when={entry()?.provider}>
|
|
169
|
-
<text attributes={TextAttributes.BOLD} fg={theme().text.base} 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()}
|
|
185
|
+
<Show when={!hidden()}>
|
|
186
|
+
<box flexDirection="column">
|
|
187
|
+
<box
|
|
188
|
+
flexDirection="row"
|
|
189
|
+
justifyContent="space-between"
|
|
190
|
+
onMouseUp={() => setCollapsed((value) => !value)}
|
|
191
|
+
>
|
|
192
|
+
<text attributes={TextAttributes.BOLD} fg={theme().text.base}>
|
|
193
|
+
{collapsed() ? "▶" : "▼"} Model
|
|
178
194
|
</text>
|
|
179
|
-
<
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
195
|
+
<Show when={showMultiplier()}>
|
|
196
|
+
<text
|
|
197
|
+
fg={peak() ? theme().text.feedback.warning.base : theme().text.base}
|
|
198
|
+
wrapMode="none"
|
|
199
|
+
>
|
|
200
|
+
{`×${multiplier()}`}
|
|
201
|
+
</text>
|
|
202
|
+
</Show>
|
|
203
|
+
</box>
|
|
204
|
+
<Show when={!collapsed()}>
|
|
205
|
+
<Show when={model()} fallback={<text fg={theme().text.muted}>No model selected</text>}>
|
|
206
|
+
<Show
|
|
207
|
+
when={!freeLabel()}
|
|
208
|
+
fallback={
|
|
209
|
+
<text fg={theme().hue.accent[200]} attributes={TextAttributes.BOLD}>
|
|
210
|
+
Free
|
|
184
211
|
</text>
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
212
|
+
}
|
|
213
|
+
>
|
|
214
|
+
<For each={rates()}>
|
|
215
|
+
{(row) => (
|
|
216
|
+
<box
|
|
217
|
+
flexDirection="row"
|
|
218
|
+
justifyContent={alignRight() ? "space-between" : "flex-start"}
|
|
219
|
+
gap={1}
|
|
220
|
+
>
|
|
221
|
+
<text flexShrink={0} fg={theme().text.base}>
|
|
222
|
+
{alignRight() ? row.label : row.label.padEnd(labelWidth())}
|
|
223
|
+
</text>
|
|
224
|
+
<text
|
|
225
|
+
flexShrink={0}
|
|
226
|
+
fg={row.muted ? theme().text.muted : theme().hue.accent[200]}
|
|
227
|
+
>
|
|
228
|
+
{row.value}
|
|
229
|
+
</text>
|
|
230
|
+
</box>
|
|
231
|
+
)}
|
|
232
|
+
</For>
|
|
233
|
+
</Show>
|
|
198
234
|
</Show>
|
|
199
235
|
</Show>
|
|
200
|
-
</
|
|
201
|
-
</
|
|
236
|
+
</box>
|
|
237
|
+
</Show>
|
|
202
238
|
)
|
|
203
239
|
}
|
package/src/tweaks/model.ts
CHANGED
|
@@ -44,20 +44,40 @@ export const modelTweak = defineTweak({
|
|
|
44
44
|
default: true,
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
|
-
id: "
|
|
48
|
-
title: "
|
|
49
|
-
description: "Show the
|
|
47
|
+
id: "peak",
|
|
48
|
+
title: "Peak state",
|
|
49
|
+
description: "Show the model's current price multiplier in the card header.",
|
|
50
50
|
category: "Display",
|
|
51
51
|
kind: "boolean",
|
|
52
52
|
default: true,
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
|
-
id: "
|
|
56
|
-
title: "
|
|
57
|
-
description: "
|
|
55
|
+
id: "align",
|
|
56
|
+
title: "Price alignment",
|
|
57
|
+
description: "Start the prices on one column, or push them against the right edge.",
|
|
58
|
+
category: "Display",
|
|
59
|
+
kind: "select",
|
|
60
|
+
default: "right",
|
|
61
|
+
values: ["left", "right"],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "free",
|
|
65
|
+
title: "Free models",
|
|
66
|
+
description:
|
|
67
|
+
"Show the paid list price, a Free label, or no card at all when the selected model is free.",
|
|
68
|
+
category: "Display",
|
|
69
|
+
kind: "select",
|
|
70
|
+
default: "reference",
|
|
71
|
+
values: ["reference", "label", "hidden"],
|
|
72
|
+
labels: ["List price", "Free label", "Hide card"],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "peakOnly",
|
|
76
|
+
title: "Peak only",
|
|
77
|
+
description: "Show the multiplier only during a peak window.",
|
|
58
78
|
category: "Display",
|
|
59
79
|
kind: "boolean",
|
|
60
|
-
default:
|
|
80
|
+
default: false,
|
|
61
81
|
},
|
|
62
82
|
],
|
|
63
83
|
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ModelPrice } from "./cost"
|
|
2
|
+
|
|
3
|
+
/** A catalog entry, narrowed to the fields the model card reads. */
|
|
4
|
+
export interface CatalogModel {
|
|
5
|
+
readonly id: string
|
|
6
|
+
readonly modelID: string
|
|
7
|
+
readonly providerID: string
|
|
8
|
+
readonly name: string
|
|
9
|
+
readonly cost: readonly ModelPrice[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Whether a price list charges anything at any of its rates. */
|
|
13
|
+
export function charged(prices: readonly ModelPrice[]): boolean {
|
|
14
|
+
return prices.some((price) => price.input > 0 || price.output > 0 || price.cache.read > 0)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The paid entry behind a free promo. Providers name the promo after the model
|
|
19
|
+
* it borrows, so dropping a trailing `Free` and matching the same provider
|
|
20
|
+
* finds the price the model lists at when it is not on the house.
|
|
21
|
+
*/
|
|
22
|
+
export function paidSibling(
|
|
23
|
+
models: readonly CatalogModel[],
|
|
24
|
+
selected: CatalogModel,
|
|
25
|
+
): CatalogModel | undefined {
|
|
26
|
+
const base = selected.name.replace(/\s*\(?free\)?$/i, "").trim()
|
|
27
|
+
if (base.length === 0 || base === selected.name) return undefined
|
|
28
|
+
|
|
29
|
+
const candidates = models.filter(
|
|
30
|
+
(candidate) =>
|
|
31
|
+
candidate.providerID === selected.providerID &&
|
|
32
|
+
candidate.id !== selected.id &&
|
|
33
|
+
charged(candidate.cost) &&
|
|
34
|
+
(candidate.name === base || candidate.name.startsWith(`${base} `)),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
candidates.sort(
|
|
38
|
+
(a, b) => Number(b.name === base) - Number(a.name === base) || a.name.length - b.name.length,
|
|
39
|
+
)
|
|
40
|
+
return candidates[0]
|
|
41
|
+
}
|