opencode-tweaks 0.0.0-stage → 0.4.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.
@@ -0,0 +1,101 @@
1
+ import type { QuotaWindowKey, QuotaWindowSnapshot } from "../rpc"
2
+
3
+ export function isRecord(value: unknown): value is Record<string, unknown> {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value)
5
+ }
6
+
7
+ export function text(value: unknown): string | undefined {
8
+ return typeof value === "string" && value.length > 0 ? value : undefined
9
+ }
10
+
11
+ export function numeric(value: unknown): number | null {
12
+ const parsed =
13
+ typeof value === "string" ? Number(value) : typeof value === "number" ? value : Number.NaN
14
+ return Number.isFinite(parsed) ? parsed : null
15
+ }
16
+
17
+ function resetIso(value: unknown): string | undefined {
18
+ const raw = text(value)
19
+ if (!raw) return undefined
20
+
21
+ const time = Date.parse(raw)
22
+ return Number.isFinite(time) ? new Date(time).toISOString() : undefined
23
+ }
24
+
25
+ function consoleWindow(
26
+ key: QuotaWindowKey,
27
+ value: unknown,
28
+ fallbackReset: string | undefined,
29
+ ): QuotaWindowSnapshot | undefined {
30
+ if (!isRecord(value)) return undefined
31
+
32
+ const limit = numeric(value.limitMicroCents)
33
+ const used = numeric(value.usedMicroCents)
34
+ if (limit === null || used === null || limit < 0 || used < 0) return undefined
35
+
36
+ const usedShare =
37
+ limit === 0 ? (used > 0 ? 100 : 0) : Math.min(100, Math.max(0, (used / limit) * 100))
38
+ const percentUsed = Math.round(usedShare)
39
+ const resetsAt = resetIso(value.resetsAt) ?? resetIso(fallbackReset)
40
+ if (!resetsAt) return undefined
41
+
42
+ return {
43
+ key,
44
+ percentRemaining: 100 - percentUsed,
45
+ percentRemainingExact: 100 - usedShare,
46
+ resetsAt,
47
+ }
48
+ }
49
+
50
+ function keyWindow(key: QuotaWindowKey, value: unknown): QuotaWindowSnapshot | undefined {
51
+ if (!isRecord(value)) return undefined
52
+
53
+ const percent = numeric(value.percent)
54
+ if (percent === null || percent < 0 || percent > 100) return undefined
55
+
56
+ const resetsAt = resetIso(value.resetsAt)
57
+ if (!resetsAt) return undefined
58
+
59
+ const remaining = value.status === "rate-limited" ? 0 : 100 - percent
60
+ return {
61
+ key,
62
+ percentRemaining: remaining,
63
+ percentRemainingExact: remaining,
64
+ resetsAt,
65
+ }
66
+ }
67
+
68
+ /** The Console `access.meters` response, in the order the sidebar shows. */
69
+ export function consoleWindows(
70
+ meters: Record<string, unknown>,
71
+ endsAt: string | undefined,
72
+ ): QuotaWindowSnapshot[] {
73
+ const windows: QuotaWindowSnapshot[] = []
74
+
75
+ for (const [key, meter] of [
76
+ ["5h", "fiveHour"],
77
+ ["7d", "week"],
78
+ ["30d", "month"],
79
+ ] as const) {
80
+ const window = consoleWindow(key, meters[meter], endsAt)
81
+ if (window) windows.push(window)
82
+ }
83
+
84
+ return windows
85
+ }
86
+
87
+ /** The Zen `/v1/usage` response, in the order the sidebar shows. */
88
+ export function keyWindows(usage: Record<string, unknown>): QuotaWindowSnapshot[] {
89
+ const windows: QuotaWindowSnapshot[] = []
90
+
91
+ for (const [key, meter] of [
92
+ ["5h", "rolling"],
93
+ ["7d", "weekly"],
94
+ ["30d", "monthly"],
95
+ ] as const) {
96
+ const window = keyWindow(key, usage[meter])
97
+ if (window) windows.push(window)
98
+ }
99
+
100
+ return windows
101
+ }
@@ -0,0 +1,224 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+ import type { QuotaSnapshot, QuotaWindowSnapshot } from "../rpc"
3
+ import { errorText } from "./format"
4
+ import { consoleWindows, isRecord, keyWindows, text } from "./parse"
5
+
6
+ const PROVIDER = "OpenCode Go"
7
+ const CONSOLE_URL = "https://opencode.ai/console"
8
+ const ZEN_USAGE_URL = "https://opencode.ai/zen/go/v1/usage"
9
+ const REQUEST_TIMEOUT_MS = 15_000
10
+
11
+ type ConsoleAuth = {
12
+ readonly kind: "console"
13
+ readonly access: string
14
+ readonly server: string
15
+ readonly orgID?: string
16
+ }
17
+
18
+ type KeyAuth = {
19
+ readonly kind: "key"
20
+ readonly key: string
21
+ }
22
+
23
+ type Auth = ConsoleAuth | KeyAuth
24
+
25
+ type Connection = NonNullable<
26
+ Awaited<ReturnType<Plugin.Context["integration"]["connection"]["active"]>>
27
+ >
28
+ type Credential = NonNullable<
29
+ Awaited<ReturnType<Plugin.Context["integration"]["connection"]["resolve"]>>
30
+ >
31
+
32
+ async function activeConnection(
33
+ context: Plugin.Context,
34
+ integrationID: string,
35
+ ): Promise<Connection | undefined> {
36
+ try {
37
+ return await context.integration.connection.active(integrationID)
38
+ } catch {
39
+ return undefined
40
+ }
41
+ }
42
+
43
+ async function resolveConnection(
44
+ context: Plugin.Context,
45
+ connection: Connection,
46
+ ): Promise<Credential | undefined> {
47
+ try {
48
+ return await context.integration.connection.resolve(connection)
49
+ } catch {
50
+ return undefined
51
+ }
52
+ }
53
+
54
+ /**
55
+ * The active OpenCode Console OAuth login authorizes the Go status endpoint.
56
+ * Without one, an OpenCode Go or OpenCode API key uses the public usage
57
+ * endpoint. The environment variable stays as a last fallback.
58
+ */
59
+ async function readAuth(context: Plugin.Context): Promise<Auth | null> {
60
+ const consoleConnection = await activeConnection(context, "opencode")
61
+ const consoleValue = consoleConnection
62
+ ? await resolveConnection(context, consoleConnection)
63
+ : undefined
64
+
65
+ if (consoleValue?.type === "oauth") {
66
+ const access = text(consoleValue.access)
67
+ const metadata = isRecord(consoleValue.metadata) ? consoleValue.metadata : undefined
68
+ if (access) {
69
+ return {
70
+ kind: "console",
71
+ access,
72
+ server: text(metadata?.server) ?? CONSOLE_URL,
73
+ orgID: text(metadata?.orgID),
74
+ }
75
+ }
76
+ }
77
+
78
+ for (const integrationID of ["opencode-go", "opencode"]) {
79
+ const connection = await activeConnection(context, integrationID)
80
+ const value = connection ? await resolveConnection(context, connection) : undefined
81
+ if (value?.type !== "key") continue
82
+
83
+ const key = value.key.trim()
84
+ if (key) return { kind: "key", key }
85
+ }
86
+
87
+ const envKey = text(process.env.OPENCODE_API_KEY)?.trim()
88
+ return envKey ? { kind: "key", key: envKey } : null
89
+ }
90
+
91
+ async function withTimeout<T>(
92
+ signal: AbortSignal,
93
+ run: (signal: AbortSignal) => Promise<T>,
94
+ ): Promise<T> {
95
+ const controller = new AbortController()
96
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
97
+ const abort = () => controller.abort()
98
+
99
+ if (signal.aborted) controller.abort()
100
+ else signal.addEventListener("abort", abort, { once: true })
101
+
102
+ try {
103
+ return await run(controller.signal)
104
+ } finally {
105
+ clearTimeout(timer)
106
+ signal.removeEventListener("abort", abort)
107
+ }
108
+ }
109
+
110
+ async function readConsole(auth: ConsoleAuth, signal: AbortSignal) {
111
+ const headers: Record<string, string> = {
112
+ Authorization: `Bearer ${auth.access}`,
113
+ Accept: "application/json",
114
+ }
115
+ if (auth.orgID) headers["x-org-id"] = auth.orgID
116
+
117
+ const response = await fetch(`${auth.server.replace(/\/+$/u, "")}/api/go/status`, {
118
+ headers,
119
+ signal,
120
+ })
121
+ if (response.status === 404) throw new Error("OpenCode Go is not active for this account")
122
+ if (!response.ok) throw new Error(`OpenCode Console API error ${response.status}`)
123
+
124
+ const payload: unknown = await response.json()
125
+ const access = isRecord(payload) && isRecord(payload.access) ? payload.access : undefined
126
+ const meters = access && isRecord(access.meters) ? access.meters : undefined
127
+ if (!access || !meters) throw new Error("OpenCode Go is not active for this account")
128
+
129
+ return consoleWindows(meters, text(access.endsAt))
130
+ }
131
+
132
+ async function readKey(auth: KeyAuth, signal: AbortSignal) {
133
+ const response = await fetch(ZEN_USAGE_URL, {
134
+ headers: {
135
+ Authorization: `Bearer ${auth.key}`,
136
+ Accept: "application/json",
137
+ },
138
+ signal,
139
+ })
140
+ if (response.status === 403) throw new Error("OpenCode Go is not active for this key")
141
+ if (!response.ok) throw new Error(`OpenCode Go API error ${response.status}`)
142
+
143
+ const payload: unknown = await response.json()
144
+ const usage = isRecord(payload) && isRecord(payload.usage) ? payload.usage : undefined
145
+ if (!usage) throw new Error("OpenCode Go usage is unavailable")
146
+
147
+ return keyWindows(usage)
148
+ }
149
+
150
+ /** Reads the active OpenCode Go quota and always returns a snapshot, including failures. */
151
+ async function readGo(
152
+ context: Plugin.Context,
153
+ signal: AbortSignal,
154
+ ): Promise<QuotaWindowSnapshot[]> {
155
+ const auth = await readAuth(context)
156
+ if (!auth) throw new Error("OpenCode Go is not signed in")
157
+
158
+ return withTimeout(signal, (requestSignal) =>
159
+ auth.kind === "console" ? readConsole(auth, requestSignal) : readKey(auth, requestSignal),
160
+ )
161
+ }
162
+
163
+ interface QuotaSource {
164
+ /** Display name, and the credential the source knows how to read. */
165
+ readonly name: string
166
+ read(context: Plugin.Context, signal: AbortSignal): Promise<QuotaWindowSnapshot[]>
167
+ }
168
+
169
+ /**
170
+ * Providers whose quota this plugin can read, keyed by the model catalog ID so
171
+ * the TUI never names a provider. Adding one is a reader plus an entry here.
172
+ * Providers missing from the map publish no quota, and the panel stays hidden
173
+ * for them instead of showing an empty box.
174
+ */
175
+ const SOURCES: Readonly<Record<string, QuotaSource>> = {
176
+ "opencode-go": { name: PROVIDER, read: readGo },
177
+ }
178
+
179
+ /** The provider IDs this plugin can read a quota for. */
180
+ export function supportedProviders(): readonly string[] {
181
+ return Object.keys(SOURCES)
182
+ }
183
+
184
+ /**
185
+ * Reads one provider's quota, or every provider that has a source when no ID is
186
+ * given. Unknown IDs are skipped so an unsupported provider renders nothing.
187
+ */
188
+ export async function readQuotas(
189
+ context: Plugin.Context,
190
+ providerID: string | undefined,
191
+ signal: AbortSignal,
192
+ ): Promise<QuotaSnapshot[]> {
193
+ const ids = providerID ? [providerID] : supportedProviders()
194
+ const snapshots: QuotaSnapshot[] = []
195
+
196
+ for (const id of ids) {
197
+ const source = SOURCES[id]
198
+ if (source) snapshots.push(await readSource(context, id, source, signal))
199
+ }
200
+
201
+ return snapshots
202
+ }
203
+
204
+ async function readSource(
205
+ context: Plugin.Context,
206
+ providerID: string,
207
+ source: QuotaSource,
208
+ signal: AbortSignal,
209
+ ): Promise<QuotaSnapshot> {
210
+ try {
211
+ const windows = await source.read(context, signal)
212
+ if (windows.length === 0) throw new Error(`${source.name} returned no quota windows`)
213
+
214
+ return { providerID, provider: source.name, capturedAt: Date.now(), windows }
215
+ } catch (error) {
216
+ return {
217
+ providerID,
218
+ provider: source.name,
219
+ capturedAt: Date.now(),
220
+ windows: [],
221
+ error: errorText(error),
222
+ }
223
+ }
224
+ }
package/src/rpc.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { Rpc } from "@opencode/plugin/rpc"
2
+
3
+ export type QuotaWindowKey = "5h" | "7d" | "30d"
4
+
5
+ export interface QuotaWindowSnapshot {
6
+ readonly key: QuotaWindowKey
7
+ readonly percentRemaining: number
8
+ /**
9
+ * The same share without the rounding shown in the panel. Pacing reads it so
10
+ * the daily bar moves with real spending instead of jumping a whole percent
11
+ * at a time. Providers that only publish a whole percent return the same
12
+ * value as `percentRemaining`.
13
+ */
14
+ readonly percentRemainingExact?: number
15
+ readonly resetsAt: string
16
+ }
17
+
18
+ /**
19
+ * One provider's quota. `providerID` is the model catalog ID (`opencode-go`),
20
+ * so the panel can follow the selected model without knowing any provider name.
21
+ */
22
+ export interface QuotaSnapshot {
23
+ readonly providerID: string
24
+ readonly provider: string
25
+ readonly capturedAt: number
26
+ readonly windows: readonly QuotaWindowSnapshot[]
27
+ readonly error?: string
28
+ }
29
+
30
+ const windowSchema = {
31
+ type: "object",
32
+ properties: {
33
+ key: { type: "string", enum: ["5h", "7d", "30d"] },
34
+ percentRemaining: { type: "number" },
35
+ percentRemainingExact: { type: "number" },
36
+ resetsAt: { type: "string" },
37
+ },
38
+ required: ["key", "percentRemaining", "resetsAt"],
39
+ additionalProperties: false,
40
+ } as const
41
+
42
+ const snapshotSchema = {
43
+ type: "object",
44
+ properties: {
45
+ providerID: { type: "string" },
46
+ provider: { type: "string" },
47
+ capturedAt: { type: "number" },
48
+ windows: {
49
+ type: "array",
50
+ items: windowSchema,
51
+ },
52
+ error: { type: "string" },
53
+ },
54
+ required: ["providerID", "provider", "capturedAt", "windows"],
55
+ additionalProperties: false,
56
+ } as const
57
+
58
+ export const Quota = Rpc.define({
59
+ id: "opencode-tweaks.quota",
60
+ methods: {
61
+ snapshot: {
62
+ /** A provider ID reads that provider; omitting it reads every source. */
63
+ input: {
64
+ type: "object",
65
+ properties: {
66
+ providerID: { type: "string" },
67
+ },
68
+ additionalProperties: false,
69
+ } as const,
70
+ output: {
71
+ type: "object",
72
+ properties: {
73
+ snapshots: {
74
+ type: "array",
75
+ items: snapshotSchema,
76
+ },
77
+ },
78
+ required: ["snapshots"],
79
+ additionalProperties: false,
80
+ } as const,
81
+ },
82
+ },
83
+ events: {},
84
+ } as const)
package/src/tui.tsx ADDED
@@ -0,0 +1,66 @@
1
+ import { Plugin } from "@opencode/plugin/tui"
2
+ import { openTweaksMenu } from "./tweaks/menu"
3
+ import { QuotaPanel } from "./tweaks/quota-panel"
4
+ import { createSettingsStore } from "./tweaks/settings"
5
+ import { createTokenInjector } from "./tweaks/token-inject"
6
+ import { TokenInline, TokenStatus } from "./tweaks/token-slots"
7
+
8
+ export default Plugin.define({
9
+ id: "opencode-tweaks.tui",
10
+ setup(context) {
11
+ const store = createSettingsStore(context)
12
+ const tokens = createTokenInjector(context)
13
+ let menuOpen = false
14
+
15
+ context.ui.slot({
16
+ append: "sidebar.content",
17
+ render: () => <QuotaPanel context={context} store={store} />,
18
+ })
19
+
20
+ context.ui.slot({
21
+ append: "session.composer.top",
22
+ render: (input) => (
23
+ <TokenInline context={context} input={input} store={store} injector={tokens} />
24
+ ),
25
+ })
26
+
27
+ context.ui.slot({
28
+ after: "prompt.footer.status",
29
+ render: (input) => (
30
+ <TokenStatus context={context} input={input} store={store} injector={tokens} />
31
+ ),
32
+ })
33
+
34
+ context.keymap.layer(() => ({
35
+ mode: "global",
36
+ commands: [
37
+ {
38
+ id: "opencode-tweaks.tweaks",
39
+ title: "Tweaks",
40
+ description: "Open the opencode-tweaks settings menu",
41
+ group: "Tweaks",
42
+ palette: true,
43
+ slash: { name: "tweaks" },
44
+ run: async () => {
45
+ if (menuOpen) return
46
+
47
+ menuOpen = true
48
+ try {
49
+ await openTweaksMenu(context, store)
50
+ } catch (error) {
51
+ context.ui.toast.show({
52
+ variant: "error",
53
+ title: "Tweaks",
54
+ message: error instanceof Error ? error.message : String(error),
55
+ })
56
+ } finally {
57
+ menuOpen = false
58
+ }
59
+ },
60
+ },
61
+ ],
62
+ }))
63
+
64
+ return () => tokens.dispose()
65
+ },
66
+ })