opencode-groq-dictate 1.1.2 → 1.1.4

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 CHANGED
@@ -39,18 +39,23 @@ cursor, as if it had been pasted by hand.
39
39
  ## Install
40
40
 
41
41
  ```bash
42
- git clone https://github.com/LeonVeganMan/opencode-groq-dictate.git \
43
- ~/.config/opencode/plugins/opencode-groq-dictate
42
+ opencode plugin add opencode-groq-dictate
44
43
  ```
45
44
 
46
- Local plugins are discovered from `~/.config/opencode/plugins/<name>/`: no entry in
47
- `opencode.json` is needed. Restart the TUI afterwards — plugins are read at startup and
48
- there is no hot reload.
45
+ Restart the TUI afterwards — plugins are read at startup and there is no hot reload. On the
46
+ first `alt+v` the plugin asks for the Groq API key (the free tier is enough) and keeps it in
47
+ the opencode plugin storage, never in this repository. If the key is rejected (`401`) it is
48
+ dropped and asked again.
49
+
50
+ To update: `opencode plugin update`, then restart.
51
+
52
+ For hacking on it, install from a clone instead — plugins are also discovered from
53
+ `~/.config/opencode/plugins/<name>/` and need no `opencode.json` entry:
49
54
 
50
- The clone above stays the documented install — opencode loads plugins from that folder —
51
- and the package is also on npm as
52
- [`opencode-groq-dictate`](https://www.npmjs.com/package/opencode-groq-dictate) for
53
- directories and registries that index plugins.
55
+ ```bash
56
+ git clone https://github.com/LeonVeganMan/opencode-groq-dictate.git \
57
+ ~/.config/opencode/plugins/opencode-groq-dictate
58
+ ```
54
59
 
55
60
  ## Usage
56
61
 
@@ -103,10 +108,13 @@ takes, spend in total.
103
108
 
104
109
  ## Cost and quota
105
110
 
106
- - Cost is estimated on the audio length at **$0.04/hour** and appended to
111
+ - Cost is estimated on the audio length at the model's rate — **$0.04/hour** for
112
+ `whisper-large-v3-turbo`, **$0.111/hour** for `whisper-large-v3` — with Groq's 10-second
113
+ minimum billed length per request. It is appended to
107
114
  `~/.cache/groq-dictate-costs.log`, the same file and the same four columns
108
115
  (`date time seconds cost`) the older shell script wrote, so a `/dictate-cost` command
109
- keeps working unchanged.
116
+ keeps working unchanged. The model name trails as an optional fifth column, so each
117
+ line stays attributable while readers of the first four columns ignore it.
110
118
  - The **daily** request figure comes from the `x-ratelimit-*` headers of the real
111
119
  transcription. No request is spent just to read the quota.
112
120
  - The **per-minute** figure is tracked locally: timestamps of the last 60 seconds, cap
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-groq-dictate",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Groq dictation in the OpenCode v2 TUI: alt+v records, transcribes with whisper-large-v3-turbo, and pastes the text into the prompt.",
5
5
  "private": false,
6
6
  "type": "module",
package/src/voice.test.ts CHANGED
@@ -15,11 +15,17 @@ test("audio length comes from the bytes past the header", () => {
15
15
  expect(audioSeconds(44 + 16_000 * 2 * 3)).toBe(3)
16
16
  })
17
17
 
18
- test("cost is the hourly rate pro rata", () => {
19
- expect(cost(3600)).toBeCloseTo(0.04, 6)
18
+ test("cost follows the model's hourly rate", () => {
19
+ expect(cost(3600, "whisper-large-v3-turbo")).toBeCloseTo(0.04, 6)
20
+ expect(cost(3600, "whisper-large-v3")).toBeCloseTo(0.111, 6)
20
21
  expect(cost(0)).toBe(0)
21
22
  })
22
23
 
24
+ test("a request bills at least ten seconds", () => {
25
+ expect(cost(4, "whisper-large-v3-turbo")).toBeCloseTo((10 / 3600) * 0.04, 9)
26
+ expect(cost(30, "whisper-large-v3")).toBeCloseTo((30 / 3600) * 0.111, 9)
27
+ })
28
+
23
29
  test("the rpm window drops stamps older than a minute", () => {
24
30
  const now = 100_000
25
31
  expect(recentRpm([now - 61_000, now - 1_000, now], now)).toEqual([now - 1_000, now])
@@ -41,6 +47,17 @@ test("a config with the wrong types falls back to the defaults", () => {
41
47
 
42
48
  test("the cost line keeps the shell script's four columns", () => {
43
49
  expect(logLine(new Date(2026, 9, 19, 8, 5, 3), 12.34, 0.000137)).toBe("2026-10-19 08:05:03 12.3 0.000137")
50
+ expect(logLine(new Date(2026, 9, 19, 8, 5, 3), 12.34, 0.000137, "whisper-large-v3")).toBe(
51
+ "2026-10-19 08:05:03 12.3 0.000137 whisper-large-v3",
52
+ )
53
+ })
54
+
55
+ test("the totals ignore the optional model column", () => {
56
+ const now = new Date(2026, 9, 19, 12, 0, 0)
57
+ const log = ["2026-10-19 08:05:03 12.3 0.000137", "2026-10-19 09:00:00 30.0 0.000333 whisper-large-v3"].join("\n")
58
+ const totals = summarize(log, now)
59
+ expect(totals.today).toBeCloseTo(0.00047, 9)
60
+ expect(totals.todayCount).toBe(2)
44
61
  })
45
62
 
46
63
  test("the totals only count today's lines, and ignore junk", () => {
package/src/voice.ts CHANGED
@@ -5,8 +5,13 @@
5
5
  export const MODELS = ["whisper-large-v3-turbo", "whisper-large-v3"] as const
6
6
  export type Model = (typeof MODELS)[number]
7
7
  export const DEFAULT_MODEL: Model = "whisper-large-v3-turbo"
8
- // Both models bill the same per audio hour; turbo is only faster.
9
- export const USD_PER_HOUR = 0.04
8
+ // Groq bills per audio hour: large-v3 is ~2.8x turbo, not the same rate.
9
+ export const RATE_PER_HOUR: Record<Model, number> = {
10
+ "whisper-large-v3-turbo": 0.04,
11
+ "whisper-large-v3": 0.111,
12
+ }
13
+ // Groq bills a minimum of 10 seconds per request, however short the take.
14
+ export const MIN_BILLED_SECONDS = 10
10
15
  export const RPM_LIMIT = 20
11
16
  // A forgotten recording would upload silence for minutes; cap it.
12
17
  export const MAX_SECONDS = 60
@@ -33,8 +38,9 @@ export function audioSeconds(bytes: number): number {
33
38
  return Math.max(0, bytes - WAV_HEADER) / (SAMPLE_RATE * BYTES_PER_SAMPLE)
34
39
  }
35
40
 
36
- export function cost(seconds: number): number {
37
- return (seconds / 3600) * USD_PER_HOUR
41
+ export function cost(seconds: number, model: Model = DEFAULT_MODEL): number {
42
+ const billable = seconds > 0 ? Math.max(seconds, MIN_BILLED_SECONDS) : 0
43
+ return (billable / 3600) * RATE_PER_HOUR[model]
38
44
  }
39
45
 
40
46
  export function recentRpm(stamps: number[], now: number): number[] {
@@ -42,12 +48,14 @@ export function recentRpm(stamps: number[], now: number): number[] {
42
48
  }
43
49
 
44
50
  // Same columns the shell script wrote: `date time seconds cost`, which is what
45
- // the /dictate-cost command's awk still reads.
46
- export function logLine(at: Date, seconds: number, usd: number): string {
51
+ // the /dictate-cost command's awk still reads. The model is appended as a fifth
52
+ // column so a rate change stays attributable; older lines simply lack it.
53
+ export function logLine(at: Date, seconds: number, usd: number, model?: Model): string {
47
54
  const pad = (n: number) => String(n).padStart(2, "0")
48
55
  const date = `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`
49
56
  const time = `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`
50
- return `${date} ${time} ${seconds.toFixed(1)} ${usd.toFixed(6)}`
57
+ const line = `${date} ${time} ${seconds.toFixed(1)} ${usd.toFixed(6)}`
58
+ return model ? `${line} ${model}` : line
51
59
  }
52
60
 
53
61
  export type Totals = { today: number; total: number; todayCount: number }
package/tui.tsx CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  recentRpm,
18
18
  startTicker,
19
19
  summarize,
20
+ type Model,
20
21
  } from "./src/voice"
21
22
 
22
23
  const WAV = "/tmp/opencode-groq-dictate.wav"
@@ -119,10 +120,10 @@ export default Plugin.define({
119
120
  }
120
121
 
121
122
  // Cost bookkeeping: the log line is best effort, the totals are in memory.
122
- async function bookkeep(seconds: number) {
123
- const usd = cost(seconds)
123
+ async function bookkeep(seconds: number, model: Model) {
124
+ const usd = cost(seconds, model)
124
125
  try {
125
- await appendFile(COST_LOG, logLine(new Date(), seconds, usd) + "\n")
126
+ await appendFile(COST_LOG, logLine(new Date(), seconds, usd, model) + "\n")
126
127
  } catch (error) {
127
128
  context.ui.toast.show({ message: `Cost log: ${String(error)}`, variant: "warning" })
128
129
  }
@@ -155,7 +156,7 @@ export default Plugin.define({
155
156
  // a copy of everything dictated on disk.
156
157
  await rm(WAV, { force: true }).catch(() => {})
157
158
 
158
- const usd = await bookkeep(seconds)
159
+ const usd = await bookkeep(seconds, model)
159
160
  context.ui.toast.show({ message: `Dictated ${seconds.toFixed(1)}s · $${usd.toFixed(4)}`, variant: "success" })
160
161
  }
161
162