opencode-tweaks 0.8.0 → 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 +8 -2
- package/package.json +1 -1
- package/src/tweaks/model-panel.tsx +102 -54
- package/src/tweaks/model.ts +11 -0
- package/src/usage/catalog.ts +41 -0
package/README.md
CHANGED
|
@@ -198,8 +198,9 @@ The cost settings used to live in the Tokens entry. A choice saved there before
|
|
|
198
198
|
### Model
|
|
199
199
|
|
|
200
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.
|
|
202
|
-
|
|
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
|
draws them as a price list with the labels on the left and the values in the theme accent. `Price alignment` picks
|
|
@@ -207,6 +208,10 @@ whether the prices start on one shared column (`left`) or sit against the right
|
|
|
207
208
|
price that applies to the session's context, so a long-context tier shows the higher rate with its threshold
|
|
208
209
|
(`>512K`).
|
|
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
|
+
|
|
210
215
|
`Peak state` puts the price multiplier on the right of the card header for schedules the plugin knows, such as
|
|
211
216
|
DeepSeek and OpenCode Go: `×2` in the warning colour during the higher window and `×1` in the normal text colour off
|
|
212
217
|
it. A model without a schedule shows nothing. The marker stays visible when the card is collapsed. `Peak only` hides
|
|
@@ -238,6 +243,7 @@ src/
|
|
|
238
243
|
messages.ts message shape and turn selection
|
|
239
244
|
tokens.ts token sums and formatting
|
|
240
245
|
cost.ts list prices, estimates, and cost formatting
|
|
246
|
+
catalog.ts catalog entries and paid sibling lookup
|
|
241
247
|
price-window.ts provider peak hours and holidays
|
|
242
248
|
tui.tsx terminal entry: commands and slots
|
|
243
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"
|
|
@@ -21,6 +22,7 @@ const CACHE = setting("cache")
|
|
|
21
22
|
const PEAK = setting("peak")
|
|
22
23
|
const PEAK_ONLY = setting("peakOnly")
|
|
23
24
|
const ALIGN = setting("align")
|
|
25
|
+
const FREE = setting("free")
|
|
24
26
|
|
|
25
27
|
/** The ref stored on a session or returned by the composer. */
|
|
26
28
|
interface ModelRef {
|
|
@@ -62,27 +64,31 @@ function ModelBody(props: {
|
|
|
62
64
|
const peakOn = () => props.store.read(modelTweak, PEAK).value === true
|
|
63
65
|
const peakOnly = () => props.store.read(modelTweak, PEAK_ONLY).value === true
|
|
64
66
|
const alignRight = () => props.store.read(modelTweak, ALIGN).value === "right"
|
|
67
|
+
const freeMode = () => String(props.store.read(modelTweak, FREE).value)
|
|
65
68
|
|
|
66
|
-
/**
|
|
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
|
+
*/
|
|
67
74
|
const model = createMemo<ModelRef | undefined>(() => {
|
|
68
|
-
const session = props.context.data.session.get(props.input.sessionID)
|
|
69
|
-
if (session?.model) return session.model
|
|
70
|
-
|
|
71
75
|
const current = props.context.ui.model.current()
|
|
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
|
-
if (!ref) return
|
|
79
|
-
|
|
80
|
-
return (
|
|
81
|
-
(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
(candidate.modelID === ref.id || candidate.id === ref.id),
|
|
85
|
-
)?.cost ?? []
|
|
86
|
+
if (!ref) return undefined
|
|
87
|
+
|
|
88
|
+
return catalog().find(
|
|
89
|
+
(candidate) =>
|
|
90
|
+
candidate.providerID === ref.providerID &&
|
|
91
|
+
(candidate.modelID === ref.id || candidate.id === ref.id),
|
|
86
92
|
)
|
|
87
93
|
})
|
|
88
94
|
|
|
@@ -93,12 +99,31 @@ function ModelBody(props: {
|
|
|
93
99
|
})
|
|
94
100
|
|
|
95
101
|
const price = createMemo(() => {
|
|
96
|
-
const list =
|
|
97
|
-
return list.length > 0 ? priceFor(list, contextSize()) : undefined
|
|
102
|
+
const list = entry()?.cost
|
|
103
|
+
return list && list.length > 0 ? priceFor(list, contextSize()) : undefined
|
|
98
104
|
})
|
|
99
105
|
|
|
100
|
-
|
|
106
|
+
/** A free model bills zero on every rate the card can show. */
|
|
107
|
+
const freeModel = createMemo(() => {
|
|
101
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()
|
|
102
127
|
if (!value) return []
|
|
103
128
|
|
|
104
129
|
const rows: { label: string; value: string; muted?: boolean }[] = []
|
|
@@ -116,6 +141,15 @@ function ModelBody(props: {
|
|
|
116
141
|
return rows
|
|
117
142
|
})
|
|
118
143
|
|
|
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
|
+
})
|
|
149
|
+
|
|
150
|
+
/** `Hide card` removes the panel entirely while a free model is selected. */
|
|
151
|
+
const hidden = () => freeModel() && freeMode() === "hidden"
|
|
152
|
+
|
|
119
153
|
/** The widest label, so the prices start on one column. */
|
|
120
154
|
const labelWidth = createMemo(() =>
|
|
121
155
|
rates().reduce((width, row) => Math.max(width, row.label.length), 0),
|
|
@@ -148,44 +182,58 @@ function ModelBody(props: {
|
|
|
148
182
|
})
|
|
149
183
|
|
|
150
184
|
return (
|
|
151
|
-
<
|
|
152
|
-
<box
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
{
|
|
159
|
-
|
|
160
|
-
<Show when={showMultiplier()}>
|
|
161
|
-
<text
|
|
162
|
-
fg={peak() ? theme().text.feedback.warning.base : theme().text.base}
|
|
163
|
-
wrapMode="none"
|
|
164
|
-
>
|
|
165
|
-
{`×${multiplier()}`}
|
|
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
|
|
166
194
|
</text>
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
<text
|
|
182
|
-
|
|
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
|
|
183
211
|
</text>
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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>
|
|
234
|
+
</Show>
|
|
187
235
|
</Show>
|
|
188
|
-
</
|
|
189
|
-
</
|
|
236
|
+
</box>
|
|
237
|
+
</Show>
|
|
190
238
|
)
|
|
191
239
|
}
|
package/src/tweaks/model.ts
CHANGED
|
@@ -60,6 +60,17 @@ export const modelTweak = defineTweak({
|
|
|
60
60
|
default: "right",
|
|
61
61
|
values: ["left", "right"],
|
|
62
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
|
+
},
|
|
63
74
|
{
|
|
64
75
|
id: "peakOnly",
|
|
65
76
|
title: "Peak only",
|
|
@@ -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
|
+
}
|