opencode-tweaks 0.6.0 → 0.7.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 +21 -4
- package/package.json +1 -1
- package/src/tui.tsx +6 -0
- package/src/tweaks/cost.ts +1 -2
- package/src/tweaks/model-panel.tsx +185 -0
- package/src/tweaks/model.ts +63 -0
- package/src/tweaks/registry.ts +2 -1
- package/src/tweaks/usage-slots.tsx +4 -27
- package/src/usage/cost.ts +9 -0
- package/src/usage/messages.ts +2 -0
- package/src/usage/price-window.ts +35 -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
|
|
|
@@ -179,11 +180,12 @@ Costs come from what the provider reports, so a free plan shows `$0.00`. `Cost d
|
|
|
179
180
|
`Auto` keeps two decimals and grows until the first significant digit shows (`$0.004`), while a fixed choice always
|
|
180
181
|
keeps that many (`$0.0042`).
|
|
181
182
|
|
|
182
|
-
`Price windows` marks
|
|
183
|
+
`Price windows` marks rounds billed above the catalog price. DeepSeek doubles its listed prices from 01:00 to
|
|
183
184
|
04:00 and from 06:00 to 10:00 UTC, Monday to Friday, and bills Chinese public holidays at the off-peak rate; OpenCode
|
|
184
|
-
Go publishes the same DeepSeek rates. The badge reads `peak ×2`
|
|
185
|
-
|
|
186
|
-
`src/usage/price-window.ts` when the
|
|
185
|
+
Go publishes the same DeepSeek rates. The badge reads `peak ×2` beside the turn cost when that round started in a peak
|
|
186
|
+
window, so it stays on screen after the window closes. The catalog already holds the off-peak price, so the figures
|
|
187
|
+
themselves stay the listed rate. The 2026 and 2027 holidays are built in; update `src/usage/price-window.ts` when the
|
|
188
|
+
next calendar is published.
|
|
187
189
|
|
|
188
190
|
`Estimate cost` fills in a price the provider did not report, using the model's list price from the same catalog
|
|
189
191
|
OpenCode already synced: prompt, completion, reasoning billed as output, and cache traffic, each at its own rate. A
|
|
@@ -193,6 +195,19 @@ per turn, and the estimate follows the API rates instead. Costs use the raw coun
|
|
|
193
195
|
|
|
194
196
|
The cost settings used to live in the Tokens entry. A choice saved there before the split keeps applying.
|
|
195
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`.
|
|
202
|
+
|
|
203
|
+
`Input price`, `Output price`, and `Cache read price` choose the rates, all in dollars per million tokens. The card
|
|
204
|
+
uses the catalog price that applies to the session's context, so a long-context tier shows the higher rate with its
|
|
205
|
+
threshold (`>512K`).
|
|
206
|
+
|
|
207
|
+
`Peak state` adds the provider's live peak marker for schedules the plugin knows, such as DeepSeek and OpenCode Go:
|
|
208
|
+
`peak ×2 now` during the higher window and `off-peak` otherwise. The badge on the turn cost still records the window
|
|
209
|
+
each round started in.
|
|
210
|
+
|
|
196
211
|
## Development
|
|
197
212
|
|
|
198
213
|
```sh
|
|
@@ -231,6 +246,8 @@ src/
|
|
|
231
246
|
quota-panel.tsx sidebar quota panel
|
|
232
247
|
tokens.ts token tweak definition
|
|
233
248
|
cost.ts cost tweak definition
|
|
249
|
+
model.ts model tweak definition
|
|
250
|
+
model-panel.tsx sidebar model price card
|
|
234
251
|
usage-slots.tsx inline and status line figures
|
|
235
252
|
usage-inject.ts splice into the host's lines
|
|
236
253
|
```
|
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) => (
|
package/src/tweaks/cost.ts
CHANGED
|
@@ -60,8 +60,7 @@ export const costTweak = defineTweak({
|
|
|
60
60
|
{
|
|
61
61
|
id: "windows",
|
|
62
62
|
title: "Price windows",
|
|
63
|
-
description:
|
|
64
|
-
"Mark when the provider charges more than the catalog price, such as DeepSeek peak hours.",
|
|
63
|
+
description: "Mark rounds billed above the catalog price, such as DeepSeek peak hours.",
|
|
65
64
|
category: "Display",
|
|
66
65
|
kind: "boolean",
|
|
67
66
|
default: true,
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { Context, SlotMap } from "@opencode/plugin/tui/context"
|
|
2
|
+
import { TextAttributes } from "@opentui/core"
|
|
3
|
+
import { createEffect, createMemo, createSignal, 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
|
+
|
|
57
|
+
const inputOn = () => props.store.read(modelTweak, INPUT).value === true
|
|
58
|
+
const outputOn = () => props.store.read(modelTweak, OUTPUT).value === true
|
|
59
|
+
const cacheOn = () => props.store.read(modelTweak, CACHE).value === true
|
|
60
|
+
const variantOn = () => props.store.read(modelTweak, VARIANT).value === true
|
|
61
|
+
const peakOn = () => props.store.read(modelTweak, PEAK).value === true
|
|
62
|
+
|
|
63
|
+
/** The session's model, or the composer's while the session has none yet. */
|
|
64
|
+
const model = createMemo<ModelRef | undefined>(() => {
|
|
65
|
+
const session = props.context.data.session.get(props.input.sessionID)
|
|
66
|
+
if (session?.model) return session.model
|
|
67
|
+
|
|
68
|
+
const current = props.context.ui.model.current()
|
|
69
|
+
return current
|
|
70
|
+
? { providerID: current.providerID, id: current.modelID, variant: current.variant }
|
|
71
|
+
: undefined
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
/** The catalog entry for that model, with what the panel needs to show it. */
|
|
75
|
+
const entry = createMemo(() => {
|
|
76
|
+
const ref = model()
|
|
77
|
+
if (!ref) return undefined
|
|
78
|
+
|
|
79
|
+
const found = (props.context.data.location.model.list() ?? []).find(
|
|
80
|
+
(candidate) =>
|
|
81
|
+
candidate.providerID === ref.providerID &&
|
|
82
|
+
(candidate.modelID === ref.id || candidate.id === ref.id),
|
|
83
|
+
)
|
|
84
|
+
const provider = (props.context.data.location.provider.list() ?? []).find(
|
|
85
|
+
(candidate) => candidate.id === ref.providerID,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
ref,
|
|
90
|
+
name: found?.name ?? ref.id,
|
|
91
|
+
provider: provider?.name,
|
|
92
|
+
costs: found?.cost ?? [],
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
/** The context the session has read so far, which picks a long-context tier. */
|
|
97
|
+
const contextSize = createMemo(() => {
|
|
98
|
+
const tokens = props.context.data.session.get(props.input.sessionID)?.tokens
|
|
99
|
+
return tokens ? tokens.input + tokens.cache.read : 0
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const price = createMemo(() => {
|
|
103
|
+
const costs = entry()?.costs
|
|
104
|
+
return costs?.length ? priceFor(costs, contextSize()) : undefined
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const rates = createMemo(() => {
|
|
108
|
+
const value = price()
|
|
109
|
+
if (!value) return undefined
|
|
110
|
+
|
|
111
|
+
const parts: string[] = []
|
|
112
|
+
if (inputOn()) parts.push(`in ${formatRate(value.input)}`)
|
|
113
|
+
if (outputOn()) parts.push(`out ${formatRate(value.output)}`)
|
|
114
|
+
if (cacheOn()) parts.push(`cache ${formatRate(value.cache.read)}`)
|
|
115
|
+
if (parts.length > 0 && value.tier) {
|
|
116
|
+
parts.push(`>${formatTokens(value.tier.size, "compact")}`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return parts.length > 0 ? parts.join(" · ") : undefined
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const variant = () => (variantOn() ? entry()?.ref.variant : undefined)
|
|
123
|
+
|
|
124
|
+
const subtitle = () => {
|
|
125
|
+
const parts = [entry()?.provider, variant()].filter(
|
|
126
|
+
(part): part is string => typeof part === "string" && part.length > 0,
|
|
127
|
+
)
|
|
128
|
+
return parts.length > 0 ? parts.join(" · ") : undefined
|
|
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 peakLabel = () => {
|
|
143
|
+
if (!peakOn() || !scheduled()) return undefined
|
|
144
|
+
const window = peak()
|
|
145
|
+
return window ? `${window.label} ×${window.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
|
+
<text attributes={TextAttributes.BOLD} fg={theme().text.base}>
|
|
161
|
+
Model
|
|
162
|
+
</text>
|
|
163
|
+
<Show when={entry()} fallback={<text fg={theme().text.muted}>No model selected</text>}>
|
|
164
|
+
<text fg={theme().text.base} wrapMode="word">
|
|
165
|
+
{entry()?.name}
|
|
166
|
+
</text>
|
|
167
|
+
<Show when={subtitle()}>
|
|
168
|
+
<text fg={theme().text.muted} wrapMode="word">
|
|
169
|
+
{subtitle()}
|
|
170
|
+
</text>
|
|
171
|
+
</Show>
|
|
172
|
+
<Show when={rates()}>
|
|
173
|
+
<text fg={theme().text.muted} wrapMode="word">
|
|
174
|
+
{rates()}
|
|
175
|
+
</text>
|
|
176
|
+
</Show>
|
|
177
|
+
<Show when={peakLabel()}>
|
|
178
|
+
<text fg={peak() ? theme().text.feedback.warning.base : theme().text.muted}>
|
|
179
|
+
{peakLabel()}
|
|
180
|
+
</text>
|
|
181
|
+
</Show>
|
|
182
|
+
</Show>
|
|
183
|
+
</box>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
@@ -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)
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
priceFor,
|
|
11
11
|
} from "../usage/cost"
|
|
12
12
|
import { footerMessage, replyTurnMessages, type UsageMessage } from "../usage/messages"
|
|
13
|
-
import { formatPriceWindow, type PriceModel,
|
|
13
|
+
import { formatPriceWindow, type PriceModel, turnPriceWindow } from "../usage/price-window"
|
|
14
14
|
import { formatTokens, sessionTokens, type TokenCount, usageTokens } from "../usage/tokens"
|
|
15
15
|
import { costTweak } from "./cost"
|
|
16
16
|
import type { SettingsStore } from "./settings"
|
|
@@ -84,19 +84,6 @@ function useUsage(props: {
|
|
|
84
84
|
|
|
85
85
|
const sessions = props.context.data.session
|
|
86
86
|
|
|
87
|
-
const [now, setNow] = createSignal(Date.now())
|
|
88
|
-
|
|
89
|
-
// The badge follows the clock, not the transcript: a line that stays on
|
|
90
|
-
// screen across a rate change has to update on its own. The timer exists
|
|
91
|
-
// only while the cost tweak and its price windows are on.
|
|
92
|
-
createEffect(() => {
|
|
93
|
-
if (!costOn() || !windowsOn() || (!promptCost() && !totalCost())) return
|
|
94
|
-
|
|
95
|
-
setNow(Date.now())
|
|
96
|
-
const timer = setInterval(() => setNow(Date.now()), 30_000)
|
|
97
|
-
onCleanup(() => clearInterval(timer))
|
|
98
|
-
})
|
|
99
|
-
|
|
100
87
|
// List prices come from the model catalog the TUI already synced, so an
|
|
101
88
|
// estimate costs nothing but a lookup.
|
|
102
89
|
const prices = createMemo(() => {
|
|
@@ -205,27 +192,17 @@ function useUsage(props: {
|
|
|
205
192
|
return sessions.get(sessionID)?.model ?? lastModel(messages(sessionID))
|
|
206
193
|
}
|
|
207
194
|
|
|
208
|
-
/** The higher price that applies right now, when the cost figures are on. */
|
|
209
|
-
const windowFor = (model: PriceModel | undefined) =>
|
|
210
|
-
costOn() && windowsOn() ? priceWindowFor(model, new Date(now())) : undefined
|
|
211
|
-
|
|
212
195
|
const promptCostLabel = () => {
|
|
213
196
|
const value = cost()
|
|
214
197
|
if (!costOn() || !promptCost() || value === undefined) return undefined
|
|
215
198
|
|
|
216
199
|
const label = formatCost(value, costDigits())
|
|
217
|
-
const window =
|
|
200
|
+
const window = windowsOn() ? turnPriceWindow(turn(), sessionModel()) : undefined
|
|
218
201
|
return window ? `${label} · ${formatPriceWindow(window)}` : label
|
|
219
202
|
}
|
|
220
203
|
|
|
221
|
-
const totalCostLabel = () =>
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const value = sessionCost()
|
|
225
|
-
const label = formatCost(value, costDigits())
|
|
226
|
-
const window = value > 0 ? windowFor(sessionModel()) : undefined
|
|
227
|
-
return window ? `${label} · ${formatPriceWindow(window)}` : label
|
|
228
|
-
}
|
|
204
|
+
const totalCostLabel = () =>
|
|
205
|
+
costOn() && totalCost() ? formatCost(sessionCost(), costDigits()) : undefined
|
|
229
206
|
|
|
230
207
|
return {
|
|
231
208
|
enabled: () => tokensOn() || costOn(),
|
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
|
package/src/usage/messages.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface UsageMessage {
|
|
|
13
13
|
readonly tokens?: MessageTokens
|
|
14
14
|
readonly cost?: number
|
|
15
15
|
readonly model?: { readonly providerID: string; readonly id: string }
|
|
16
|
+
/** When the step was created, which is when the provider started billing it. */
|
|
17
|
+
readonly time?: { readonly created?: number }
|
|
16
18
|
/** How the step ended; the host only draws a footer for a finished one. */
|
|
17
19
|
readonly finish?: string | null
|
|
18
20
|
readonly error?: unknown
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { UsageMessage } from "./messages"
|
|
2
|
+
|
|
1
3
|
/** The provider and model a message belongs to. */
|
|
2
4
|
export interface PriceModel {
|
|
3
5
|
readonly providerID: string
|
|
@@ -105,6 +107,19 @@ function utcMinutes(date: Date): number {
|
|
|
105
107
|
return date.getUTCHours() * 60 + date.getUTCMinutes()
|
|
106
108
|
}
|
|
107
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
|
+
|
|
108
123
|
/**
|
|
109
124
|
* The higher price that applies to a model at `at`, or `undefined` when the
|
|
110
125
|
* catalog price is the one being charged. The check is deliberately narrow:
|
|
@@ -113,10 +128,7 @@ function utcMinutes(date: Date): number {
|
|
|
113
128
|
export function priceWindowFor(model: PriceModel | undefined, at: Date): PriceWindow | undefined {
|
|
114
129
|
if (!model) return undefined
|
|
115
130
|
|
|
116
|
-
const schedule =
|
|
117
|
-
(candidate) =>
|
|
118
|
-
candidate.providers.includes(model.providerID) && model.id.startsWith(candidate.modelPrefix),
|
|
119
|
-
)
|
|
131
|
+
const schedule = scheduleFor(model)
|
|
120
132
|
if (!schedule) return undefined
|
|
121
133
|
|
|
122
134
|
if (!schedule.peakDays.includes(weekday(at))) return undefined
|
|
@@ -134,3 +146,22 @@ export function priceWindowFor(model: PriceModel | undefined, at: Date): PriceWi
|
|
|
134
146
|
export function formatPriceWindow(window: PriceWindow): string {
|
|
135
147
|
return `${window.label} ×${window.multiplier}`
|
|
136
148
|
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The window that applied to a round, from the step that started it. Each
|
|
152
|
+
* request is billed when it starts, so a round that ran during peak keeps its
|
|
153
|
+
* marker after the window closes. Only assistant steps carry a start time.
|
|
154
|
+
*/
|
|
155
|
+
export function turnPriceWindow(
|
|
156
|
+
messages: readonly UsageMessage[],
|
|
157
|
+
fallback?: PriceModel,
|
|
158
|
+
): PriceWindow | undefined {
|
|
159
|
+
const step = messages.find(
|
|
160
|
+
(message) => message.type === "assistant" && message.time?.created !== undefined,
|
|
161
|
+
)
|
|
162
|
+
const created = step?.time?.created
|
|
163
|
+
const model = step?.model ?? fallback
|
|
164
|
+
if (!model || created === undefined) return undefined
|
|
165
|
+
|
|
166
|
+
return priceWindowFor(model, new Date(created))
|
|
167
|
+
}
|