| | /** |
| | * quota-tavily — instant /quota-tavily report, no LLM turn. |
| | * |
| | * Registers the command from the config hook and answers it from |
| | * api.tavily.com/usage (GET, Bearer key, 15 s timeout), injecting the text with |
| | * noReply: true and ignored: true so it stays out of future model context. |
| | * |
| | * OpenCode cannot mark a command.execute.before command as handled: hooks only |
| | * mutate output.parts, and returning runs the command template. The hook |
| | * therefore throws an error to abort processing before the LLM runs. That error |
| | * carries the Symbol.for("quota-tavily") marker, so the abort is identifiable |
| | * rather than being a plain, anonymous failure. |
| | * |
| | * note: the default export is a V1 plugin module { id, server }. |
| | * OpenCode's legacy fallback treats every module export as a plugin and |
| | * throws "Plugin export is not a function" on non-function exports, so the |
| | * default module shape must stay; named exports are then ignored by the |
| | * and remain safe as test seams. id is required for file-based plugins. |
| | * |
| | * API key (never printed): $TAVILY_API_KEY, else the first whitespace token of |
| | * ~/.secrets/tavily-api-key. |
| | * |
| | * Error handling: no key -> "no API key"; request or abort failure -> "request |
| | * failed"; 401 -> "API key rejected"; 429 -> "rate limited"; other non-2xx or |
| | * unparseable JSON -> message plus the first 500 body chars. A 2xx with valid | | | * JSON renders the usage report. | | | * | | | * Requires Node.js 22 (oldest non-EOL LTS). Node builtins only; read-only. | | | * | | | * Public gist: https://gist.github.com/cardin/ca901db7f5a1e8e1919885bed6cb4963 | | | * Install/upgrade: | | | * curl -fsSL https://gist.githubusercontent.com/cardin/ca901db7f5a1e8e1919885bed6cb4963/raw/quota-tavily.ts \ | | | * -o ~/.config/opencode/plugins/quota-tavily.ts | | | * Or clone and pull: | | | * gh gist clone ca901db7f5a1e8e1919885bed6cb4963 ~/quota-tavily && git -C ~/quota-tavily pull | | | */ |
| | import { readFile } from "node:fs/promises"; |
| | import { homedir } from "node:os"; |
| | import { join } from "node:path"; |
| | import type { Plugin, PluginModule } from "@opencode-ai/plugin"; |
| | export const COMMAND_ID = "quota-tavily"; |
| | export const COMMAND_DESCRIPTION = "Show Tavily API quota remaining"; |
| | export const API_URL = "https://api.tavily.com/usage"; |
| | export const KEY_FILE = join(homedir(), ".secrets", "tavily-api-key"); |
| | export const REQUEST_TIMEOUT_MS = 15_000; |
| | export const MAX_ERROR_BODY_CHARS = 500; |
| | export const HANDLED_COMMAND = Symbol.for("quota-tavily"); |
| | /** api.tavily.com/usage, derived so display text can never drift from API_URL. */ |
| | const { host: API_HOST, pathname: API_PATH } = new URL(API_URL); |
| | const API_DISPLAY = `${API_HOST}${API_PATH}`; |
| | export interface TavilyUsage { |
| | key?: { |
| | usage?: number \| null; |
| | limit?: number \| null; |
| | } \| null; |
| | account?: { |
| | current_plan?: string \| null; |
| | plan_usage?: number \| null; |
| | plan_limit?: number \| null; |
| | } \| null; |
| | } | | | /** | | | * Coerce an untrusted value to a finite number, or null when it is not one. | | | * This is the single rule behind every field read below. | | | */ |
| | export function toCount(value: unknown): number \| null { |
| | return typeof value === "number" && Number.isFinite(value) ? value : null; |
| | } | | | /** Render a used/limit pair. */ |
| | export function formatCount(used: unknown, limit: unknown): string { |
| | const usedCount = toCount(used); |
| | const limitCount = toCount(limit); |
| | if (usedCount === null && limitCount === null) return "n/a"; |
| | if (limitCount === null) return `${usedCount} used / unlimited`; |
| | if (usedCount === null) return `${limitCount} limit`; |
| | if (limitCount === 0) return `${usedCount} used / 0 limit`; |
| | const remaining = limitCount - usedCount; |
| | const percentLeft = (remaining / limitCount) * 100; |
| | return `${remaining} / ${limitCount} remaining (${percentLeft.toFixed(1)}% left, ${usedCount} used)`; |
| | } | | | /** Render a parsed usage payload. */ |
| | export function formatUsage(data: TavilyUsage): string { |
| | const account = data.account ?? {}; |
| | const key = data.key ?? {}; |
| | const lines = [`Tavily quota (${API_DISPLAY}):`]; |
| | const hasPlanData = toCount(account.plan_usage) !== null \|\| toCount(account.plan_limit) !== null; |
| | const hasKeyData = toCount(key.usage) !== null \|\| toCount(key.limit) !== null; |
| | if (hasPlanData) { |
| | const planName = | | | typeof account.current_plan === "string" && account.current_plan.length > 0 |
| | ? ` (${account.current_plan})` |
| | : ""; |
| | lines.push(`Plan${planName}: ${formatCount(account.plan_usage, account.plan_limit)}`); |
| | } else if (hasKeyData) { |
| | lines.push(`Key: ${formatCount(key.usage, key.limit)}`); |
| | } else { |
| | lines.push("No usage data returned."); |
| | } |
| | return lines.join("\n"); |
| | } |
| | export function noApiKeyMessage(): string { |
| | return [ |
| | "Tavily quota: no API key found.", |
| | Set TAVILY_API_KEY or write the key to ${KEY_FILE}., |
| | ].join("\n"); |
| | } |
| | export function truncateBody(body: string): string { |
| | return body.slice(0, MAX_ERROR_BODY_CHARS); |
| | } | | | /** | | | * Turn an HTTP status and raw body into the user-facing report. | | | * Pure: no I/O, so every status branch is directly unit-testable. | | | */ |
| | export function renderUsageResponse(status: number, body: string): string { |
| | if (status === 401) { |
| | return `Tavily quota: API key rejected (401). Check TAVILY_API_KEY or ${KEY_FILE}.`; |
| | } | | | if (status === 429) { | | | return "Tavily quota: rate limited on /usage (10 requests / 10 min). Try again later."; | | | } |
| | if (status < 200 \|\| status >= 300) { |
| | return `Tavily quota: HTTP ${status} from ${API_DISPLAY}\n${truncateBody(body)}`; |
| | } | | | let data: TavilyUsage; | | | try { |
| | data = JSON.parse(body) as TavilyUsage; |
| | } catch { |
| | return `Tavily quota: invalid JSON from ${API_DISPLAY}\n${truncateBody(body)}`; |
| | } | | | return formatUsage(data); | | | } | | | /** | | | * Resolve the API key from the environment, then the key file. | | | * Parameters are injectable so tests never touch the real environment or file. | | | */ | | | export async function loadApiKey( | | | env: Record<string, string | undefined> = process.env, |
| | keyFile: string = KEY_FILE, |
| | ): Promise<string> { |
| | const fromEnv = (env.TAVILY_API_KEY ?? "").trim(); |
| | if (fromEnv) return fromEnv; |
| | try { |
| | const contents = await readFile(keyFile, "utf8"); |
| | return contents.trim().split(/\s+/)[0] ?? ""; |
| | } catch { |
| | return ""; | | | } | | | } | | | /** | | | * Fetch and render the Tavily usage report. The key is injectable so | | | * the no-key branch (and error branches) can be tested offline. | | | */ | | | export async function fetchUsage( |
| | loadKey: () => Promise<string> = loadApiKey, |
| | ): Promise<string> { |
| | const key = await loadKey(); |
| | if (!key) return noApiKeyMessage(); |
| | let response: Response; |
| | let body: string; |
| | try { |
| | response = await fetch(API_URL, { |
| | headers: { Authorization: `Bearer ${key}` }, |
| | signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), |
| | }); |
| | body = await response.text().catch(() => ""); |
| | } catch (error) { |
| | const reason = error instanceof Error ? error.message : String(error); |
| | return `Tavily quota: request failed (${reason}). Check your network.`; |
| | } | | | return renderUsageResponse(response.status, body); | | | } | | | /** Abort command processing, tagging the thrown error with the sentinel. */ |
| | function abortHandledCommand(): never { |
| | const error = new Error("quota-tavily: command handled, output already injected"); |
| | error.name = "QuotaTavilyHandled"; | | | Object.defineProperty(error, HANDLED_COMMAND, { value: true }); | | | throw error; | | | } | | | export const TavilyQuota: Plugin = async ({ client }) => { | | | /** Inject text without triggering a model reply or entering future context. */ | | | async function injectOutput(sessionID: string, text: string): Promise<void> { | | | try { |
| | await client.session.prompt({ |
| | path: { id: sessionID }, |
| | body: { |
| | noReply: true, | | | parts: [{ type: "text", text, ignored: true }], | | | }, |
| | }); |
| | } catch (error) { |
| | await client.app.log({ |
| | body: { |
| | service: "quota-tavily", |
| | level: "warn", | | | message: "Failed to inject quota output", | | | extra: { error: error instanceof Error ? error.message : String(error) }, | | | }, | | | }); | | | throw error; | | | } | | | } |
| | async function handleCommand(input: { command: string; sessionID: string }): Promise<void> { |
| | const isTarget = input.command.replace(/^\//, "") === COMMAND_ID; |
| | if (!isTarget \|\| !input.sessionID) return; |
| | await injectOutput(input.sessionID, await fetchUsage()); |
| | abortHandledCommand(); |
| | } | | | return { |
| | config: async (config) => { |
| | config.command ??= {}; |
| | config.command[COMMAND_ID] ??= { |
| | template: `/${COMMAND_ID}`, |
| | description: COMMAND_DESCRIPTION, | | | }; | | | }, | | | "command.execute.before": handleCommand, |
| | }; |
| | }; |
| | export default { | | | id: "quota-tavily", | | | server: TavilyQuota, | | | } satisfies PluginModule; |