Sign In

@christiangalsterer/opencode-requesty-plugin

Package Overview
Dependencies
Maintainers
1
Versions
6
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@christiangalsterer/opencode-requesty-plugin - npm Package Compare versions

Comparing version
1.0.0
to
1.1.0
+136
-50
dist/api.ts

@@ -1,17 +0,11 @@

/**
* Requesty Management API client.
*
* Docs: https://docs.requesty.ai/api-reference/management-apis
* Base URL: https://api-v2.requesty.ai (global)
*
* Note: the usage endpoint is documented as `GET /v1/manage/apikey/{id}/usage`
* with a JSON request body. Node/undici fetch refuses to send a body with GET,
* and the server does not accept POST on this route (404). Query parameters
* carry the same fields instead (verified: authentication reaches the handler
* with query params).
*/
/** Requesty Management API client. */
const REQUESTY_ORIGIN = "https://api-v2.requesty.ai"
const REQUESTY_ORIGIN = 'https://api-v2.requesty.ai'
const REQUEST_TIMEOUT_MS = 10_000
// Injected by opencode host; keeping definition minimal to avoid import dependency.
const logger = {
warn: (message: string) => console.warn(`[Requesty] ${message}`)
}
export type ApiKeyInfo = {

@@ -21,9 +15,7 @@ id: string

logging: boolean
/** Amount spent this month in USD. The API returns decimals as strings; coerced to number. */
monthly_spend: number
/** Monthly spending limit in USD. 0 means unlimited. Coerced to number. */
monthly_limit: number
permissions: {
manage: "none" | "read" | "write"
completions: "none" | "read" | "write"
manage: 'none' | 'read' | 'write'
completions: 'none' | 'read' | 'write'
}

@@ -34,8 +26,9 @@ group?: { id: string }

/** The API serializes decimal fields as strings — coerce them to numbers. */
function toNumber(value: unknown): number {
if (typeof value === "number") return value
if (typeof value === "string") {
function toNumber(value: unknown, field?: string): number {
if (typeof value === 'number') return value
if (typeof value === 'string') {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
if (Number.isFinite(parsed)) return parsed
}
if (field) logger.warn(`Failed to coerce field "${field}" to number: ${JSON.stringify(value)}`)
return 0

@@ -71,3 +64,3 @@ }

super(message)
this.name = "RequestyApiError"
this.name = 'RequestyApiError'
this.status = status

@@ -78,3 +71,3 @@ }

async function request<T>(apiKey: string, path: string, init?: { params?: Record<string, string> }): Promise<T> {
const url = new URL(path, REQUESTY_ORIGIN.endsWith("/") ? REQUESTY_ORIGIN : REQUESTY_ORIGIN + "/")
const url = new URL(path, REQUESTY_ORIGIN.endsWith('/') ? REQUESTY_ORIGIN : REQUESTY_ORIGIN + '/')
for (const [key, value] of Object.entries(init?.params ?? {})) {

@@ -86,11 +79,11 @@ url.searchParams.set(key, value)

response = await fetch(url, {
method: "GET",
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
Accept: 'application/json'
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
})
} catch (error) {
if (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError")) {
if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
throw new RequestyApiError(408, `Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`)

@@ -102,7 +95,8 @@ }

let message = `HTTP ${response.status}`
const raw = await response.text()
try {
const body = (await response.json()) as { error?: { message?: string } }
const body = JSON.parse(raw) as { error?: { message?: string } }
if (body?.error?.message) message = body.error.message
} catch {
// keep generic message
logger.warn(`Failed to parse API error body (HTTP ${response.status}): ${raw.slice(0, 200)}`)
}

@@ -116,7 +110,7 @@ throw new RequestyApiError(response.status, message)

export async function getApiKeySelf(apiKey: string): Promise<ApiKeyInfo> {
const info = await request<ApiKeyInfo>(apiKey, "/v1/manage/apikey/self")
const info = await request<ApiKeyInfo>(apiKey, '/v1/manage/apikey/self')
return {
...info,
monthly_spend: toNumber(info.monthly_spend),
monthly_limit: toNumber(info.monthly_limit),
monthly_spend: toNumber(info.monthly_spend, 'monthly_spend'),
monthly_limit: toNumber(info.monthly_limit, 'monthly_limit')
}

@@ -131,3 +125,3 @@ }

groupBy?: string[]
resolution?: "hour" | "day" | "month"
resolution?: 'hour' | 'day' | 'month'
}

@@ -139,5 +133,5 @@

if (query.end) params.end = query.end
if (query.groupBy && query.groupBy.length > 0) params.group_by = query.groupBy.join(",")
if (query.groupBy && query.groupBy.length > 0) params.group_by = query.groupBy.join(',')
if (query.resolution) params.resolution = query.resolution
return request<UsageResponse>(apiKey, "/v1/manage/apikey/self/usage", { params })
return request<UsageResponse>(apiKey, '/v1/manage/apikey/self/usage', { params })
}

@@ -155,22 +149,48 @@

/** Aggregated totals from a usage response. */
export type AggregatedUsage = {
models: ModelUsage[]
spend: number
inputTokens: number
outputTokens: number
totalTokens: number
}
/**
* Flatten a usage response into per-model aggregates. When the response
* contains no grouped rows (e.g. no traffic), an empty array is returned.
* Flatten a usage response into per-model aggregates and compute grand totals.
* When the response contains no grouped rows (e.g. no traffic), returns zeros.
*/
export function aggregateByModel(response: UsageResponse): ModelUsage[] {
export function aggregateByModel(response: UsageResponse): AggregatedUsage {
const byModel = new Map<string, ModelUsage>()
const totals = { spend: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0 }
for (const entry of Object.values(response.usage ?? {})) {
for (const group of entry.grouped_data ?? []) {
const raw = group.group_by_values?.model_used ?? group.group_by_values?.model_requested ?? "unknown"
const model = typeof raw === "string" && raw.length > 0 ? raw : "unknown"
const raw = group.group_by_values?.model_used ?? group.group_by_values?.model_requested ?? 'unknown'
const model = typeof raw === 'string' && raw.length > 0 ? raw : 'unknown'
const s = toNumber(group.spend, 'spend')
const i = toNumber(group.input_tokens, 'input_tokens')
const o = toNumber(group.output_tokens, 'output_tokens')
const t = toNumber(group.total_tokens, 'total_tokens')
const r = toNumber(group.completions_requests, 'completions_requests')
const current = byModel.get(model) ?? { model, spend: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0, requests: 0 }
current.spend += toNumber(group.spend)
current.inputTokens += toNumber(group.input_tokens)
current.outputTokens += toNumber(group.output_tokens)
current.totalTokens += toNumber(group.total_tokens)
current.requests += toNumber(group.completions_requests)
current.spend += s
current.inputTokens += i
current.outputTokens += o
current.totalTokens += t
current.requests += r
byModel.set(model, current)
totals.spend += s
totals.inputTokens += i
totals.outputTokens += o
totals.totalTokens += t
}
}
return [...byModel.values()].sort((a, b) => b.spend - a.spend)
return {
models: [...byModel.values()].sort((a, b) => b.spend - a.spend),
...totals
}
}

@@ -182,3 +202,3 @@

for (const entry of Object.values(response.usage ?? {})) {
total += toNumber(entry.spend)
total += toNumber(entry.spend, 'spend')
}

@@ -203,2 +223,20 @@ return total

/** RFC3339 timestamp for `days` days ago at midnight UTC. */
export function startOfRollingWindow(days: number, now = new Date()): string {
const date = new Date(now)
date.setUTCDate(date.getUTCDate() - days)
date.setUTCHours(0, 0, 0, 0)
return date.toISOString()
}
/** Keep only usage entries that fall within the current calendar month. */
export function filterUsageByMonth(response: UsageResponse, now = new Date()): UsageResponse {
const prefix = dayKey(now).slice(0, 7)
const filtered: Record<string, UsageEntry> = {}
for (const [key, entry] of Object.entries(response.usage ?? {})) {
if (key.startsWith(prefix)) filtered[key] = entry
}
return { usage: filtered }
}
/** Format a Date as a `YYYY-MM-DD` key matching the usage response (UTC). */

@@ -217,7 +255,7 @@ export function dayKey(now = new Date()): string {

if (!entry) return 0
return toNumber(entry.spend)
return toNumber(entry.spend, 'spend')
}
/**
* Average daily spend over the last `days` calendar days (including today).
* Average daily spend over the last `days` completed calendar days (excluding today).
* Days with no usage entry count as 0. Returns 0 when `days` <= 0.

@@ -228,3 +266,3 @@ */

let total = 0
for (let offset = 0; offset < days; offset++) {
for (let offset = 1; offset <= days; offset++) {
const date = new Date(now)

@@ -236,1 +274,49 @@ date.setUTCDate(date.getUTCDate() - offset)

}
export type TokenBreakdown = {
input: number
output: number
total: number
}
/** Sum input/output/total tokens for a specific day. */
export function tokensForDay(response: UsageResponse, now = new Date()): TokenBreakdown {
const key = dayKey(now)
const entry = response.usage?.[key]
if (!entry) return { input: 0, output: 0, total: 0 }
if (entry.grouped_data && entry.grouped_data.length > 0) {
return entry.grouped_data.reduce(
(acc, group) => {
acc.input += toNumber(group.input_tokens, 'input_tokens')
acc.output += toNumber(group.output_tokens, 'output_tokens')
acc.total += toNumber(group.total_tokens, 'total_tokens')
return acc
},
{ input: 0, output: 0, total: 0 }
)
}
return {
input: toNumber(entry.input_tokens, 'input_tokens'),
output: toNumber(entry.output_tokens, 'output_tokens'),
total: toNumber(entry.total_tokens, 'total_tokens')
}
}
/** Average input/output/total tokens over the last `days` completed calendar days (excluding today). */
export function avgTokensLastNDays(response: UsageResponse, days: number, now = new Date()): TokenBreakdown {
if (days <= 0) return { input: 0, output: 0, total: 0 }
const totals: TokenBreakdown = { input: 0, output: 0, total: 0 }
for (let offset = 1; offset <= days; offset++) {
const date = new Date(now)
date.setUTCDate(date.getUTCDate() - offset)
const day = tokensForDay(response, date)
totals.input += day.input
totals.output += day.output
totals.total += day.total
}
return {
input: totals.input / days,
output: totals.output / days,
total: totals.total / days
}
}
/** @jsxImportSource @opentui/solid */
import { Show, For, type JSX } from "solid-js"
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
import type { ModelUsage } from "./api"
import type { RequestyStore } from "./state"
import { Show, For, type JSX } from 'solid-js'
import type { TuiThemeCurrent } from '@opencode-ai/plugin/tui'
import type { ModelUsage, TokenBreakdown } from './api'
import type { RequestyStore } from './state'
import {

@@ -16,2 +16,3 @@ analyticsUrl,

formatTokenBreakdown,
formatTokenInline,
formatTokens,

@@ -29,4 +30,4 @@ formatUsd,

type Pace,
type SpendThresholds,
} from "./format"
type SpendThresholds
} from './format'

@@ -37,2 +38,3 @@ export type DetailDialogProps = {

thresholds: SpendThresholds
showKeyName: boolean
onClose: () => void

@@ -46,29 +48,26 @@ onRefresh: () => void

const state = () => props.store.state()
const fetchedAt = () =>
state().status === "ready"
? formatTimestamp((state() as { fetchedAt: Date }).fetchedAt)
: "—"
const fetchedAt = () => (state().status === 'ready' ? formatTimestamp((state() as { fetchedAt: Date }).fetchedAt) : '—')
return (
<box flexDirection="column" paddingLeft={2} paddingRight={2} paddingTop={1} gap={1}>
<Title store={props.store} theme={theme()} />
<box flexDirection="column" paddingLeft={2} paddingRight={2} paddingTop={1} flexGrow={1}>
<box flexGrow={1}>
<box flexDirection="column">
<Title store={props.store} theme={theme()} showKeyName={props.showKeyName} />
<Show when={state().status === "error"}>
<CenteredMessage theme={theme()} error>
{(state() as { message: string }).message}
</CenteredMessage>
</Show>
<Show when={state().status === 'error'}>
<CenteredMessage theme={theme()} error>
{(state() as { message: string }).message}
</CenteredMessage>
</Show>
<Show
when={data()}
fallback={
<CenteredMessage theme={theme()}>
{state().status === "loading" ? "Loading Requesty usage…" : "No data yet."}
</CenteredMessage>
}
>
<KpiRow store={props.store} theme={theme()} thresholds={props.thresholds} />
<BudgetSection store={props.store} theme={theme()} thresholds={props.thresholds} />
<ModelSection store={props.store} theme={theme()} />
</Show>
<Show
when={data()}
fallback={<CenteredMessage theme={theme()}>{state().status === 'loading' ? 'Loading Requesty usage…' : 'No data yet.'}</CenteredMessage>}
>
<KpiRow store={props.store} theme={theme()} thresholds={props.thresholds} />
<BudgetSection store={props.store} theme={theme()} thresholds={props.thresholds} />
<ModelSection store={props.store} theme={theme()} />
</Show>
</box>
</box>

@@ -80,3 +79,3 @@ <Footer fetchedAt={fetchedAt()} theme={theme()} />

function Title(props: { store: RequestyStore; theme: TuiThemeCurrent }): JSX.Element {
function Title(props: { store: RequestyStore; theme: TuiThemeCurrent; showKeyName: boolean }): JSX.Element {
return (

@@ -86,3 +85,3 @@ <text fg={props.theme.text}>

<a href={analyticsUrl(props.store.data()!.keyInfo.name)}>
<strong>Requesty ({props.store.data()!.keyInfo.name})</strong>
<strong>Requesty{props.showKeyName ? ` (${props.store.data()!.keyInfo.name})` : ''}</strong>
</a>

@@ -111,4 +110,4 @@ </Show>

const paceColor = (pace: Pace | undefined) => {
if (pace === "over") return props.theme.error
if (pace === "under") return props.theme.success
if (pace === 'over') return props.theme.error
if (pace === 'under') return props.theme.success
return props.theme.textMuted

@@ -122,8 +121,3 @@ }

<Metric label="Limit" value={formatUsd(limit())} theme={props.theme} color={props.theme.text} />
<Metric
label="Remaining"
value={formatUsd(limit() - spend())}
theme={props.theme}
color={severityColor(severity(), props.theme)}
/>
<Metric label="Remaining" value={formatUsd(limit() - spend())} theme={props.theme} color={severityColor(severity(), props.theme)} />
</Show>

@@ -136,7 +130,3 @@ <Show when={projectionParts()}>

color={isProjectionOverLimit(spend(), limit()) ? props.theme.error : props.theme.text}
indicator={
projectionParts()!.arrow
? { text: projectionParts()!.arrow, color: paceColor(projectionParts()!.pace) }
: undefined
}
indicator={projectionParts()!.arrow ? { text: projectionParts()!.arrow, color: paceColor(projectionParts()!.pace) } : undefined}
/>

@@ -152,8 +142,3 @@ </Show>

text: `${monthDelta()!.arrow} ${monthDelta()!.sign}${monthDelta()!.pct}%`,
color:
monthDelta()!.pct > 0
? props.theme.error
: monthDelta()!.pct < 0
? props.theme.success
: props.theme.textMuted,
color: monthDelta()!.pct > 0 ? props.theme.error : monthDelta()!.pct < 0 ? props.theme.success : props.theme.textMuted
}}

@@ -170,8 +155,10 @@ />

theme: TuiThemeCurrent
color: TuiThemeCurrent["text"]
color: TuiThemeCurrent['text']
tokens?: TokenBreakdown
}): JSX.Element {
return (
<box flexDirection="column">
<box flexDirection="column" flexGrow={1} flexBasis={0}>
<text fg={props.color}>
<strong>{props.value}</strong>
<Show when={props.tokens}> {formatTokenInline(props.tokens!.input, props.tokens!.output)}</Show>
</text>

@@ -187,3 +174,3 @@ <text fg={props.theme.textMuted}>{props.label}</text>

theme: TuiThemeCurrent
color: TuiThemeCurrent["text"]
color: TuiThemeCurrent['text']
indicator?: { text: string; color: unknown }

@@ -199,3 +186,3 @@ }): JSX.Element {

<Show when={props.indicator}>
{" "}
{' '}
<span style={{ fg: props.indicator!.color }}>{props.indicator!.text}</span>

@@ -208,7 +195,3 @@ </Show>

function BudgetSection(props: {
store: RequestyStore
theme: TuiThemeCurrent
thresholds: SpendThresholds
}): JSX.Element {
function BudgetSection(props: { store: RequestyStore; theme: TuiThemeCurrent; thresholds: SpendThresholds }): JSX.Element {
const data = () => props.store.data()!

@@ -234,3 +217,6 @@ const limit = () => data().keyInfo.monthly_limit

flexDirection="column"
padding={1}
paddingLeft={1}
paddingRight={1}
paddingTop={1}
paddingBottom={0}
gap={1}

@@ -258,8 +244,12 @@ >

<box flexDirection="row" gap={3} flexWrap="wrap">
<Metric label="Today" value={formatUsd(data().todaySpend)} theme={props.theme} color={props.theme.text} />
<Metric label="7d avg" value={formatUsd(data().avg7d)} theme={props.theme} color={props.theme.text} />
<Metric label="30d avg" value={formatUsd(data().avg30d)} theme={props.theme} color={props.theme.text} />
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={3} flexWrap="wrap">
<Metric label="Today" value={formatUsd(data().todaySpend)} theme={props.theme} color={props.theme.text} tokens={data().todayTokens} />
<Metric label="Daily avg" value={formatUsd(data().dailyAvg)} theme={props.theme} color={props.theme.text} tokens={data().dailyAvgTokens} />
</box>
<box flexDirection="row" gap={3} flexWrap="wrap">
<Metric label="7d avg" value={formatUsd(data().avg7d)} theme={props.theme} color={props.theme.text} tokens={data().avg7dTokens} />
<Metric label="30d avg" value={formatUsd(data().avg30d)} theme={props.theme} color={props.theme.text} tokens={data().avg30dTokens} />
</box>
</box>
</box>

@@ -271,7 +261,4 @@ )

return (
<box
paddingX={1}
backgroundColor={props.overBudget ? props.theme.error : props.theme.success}
>
<text fg={props.theme.text}>{props.overBudget ? "Over budget" : "On track"}</text>
<box paddingX={1} backgroundColor={props.overBudget ? props.theme.error : props.theme.success}>
<text fg={props.theme.text}>{props.overBudget ? 'Over budget' : 'On track'}</text>
</box>

@@ -295,16 +282,46 @@ )

flexDirection="column"
padding={1}
gap={1}
paddingLeft={1}
paddingRight={1}
paddingTop={1}
paddingBottom={0}
gap={0}
>
<Show
when={models().length > 0}
fallback={<text fg={props.theme.textMuted}>No model usage recorded this month.</text>}
>
<TableHeader theme={props.theme} />
<For each={models()}>
{(model) => <ModelRow model={model} totalSpend={totalSpend()} keyName={data().keyInfo.name} theme={props.theme} />}
</For>
<text fg={props.theme.textMuted}>
Total: {formatUsd(monthSpend())} across {models().length} model{models().length === 1 ? "" : "s"}
</text>
<Show when={models().length > 0} fallback={<text fg={props.theme.textMuted}>No model usage recorded this month.</text>}>
<box flexDirection="column" gap={0}>
<box paddingBottom={0.5}>
<TableHeader theme={props.theme} />
</box>
<Show
when={models().length > 5}
fallback={
<box flexDirection="column" gap={0}>
<For each={models()}>
{(model) => <ModelRow model={model} totalSpend={totalSpend()} keyName={data().keyInfo.name} theme={props.theme} />}
</For>
</box>
}
>
<scrollbox
height={5}
gap={0}
style={{
scrollbarOptions: {
trackOptions: {
foregroundColor: props.theme.primary,
backgroundColor: props.theme.background
}
}
}}
>
<For each={models()}>
{(model) => <ModelRow model={model} totalSpend={totalSpend()} keyName={data().keyInfo.name} theme={props.theme} />}
</For>
</scrollbox>
</Show>
<box paddingTop={1}>
<text fg={props.theme.textMuted}>
Total: {formatUsd(monthSpend())} across {models().length} model{models().length === 1 ? '' : 's'}
</text>
</box>
</box>
</Show>

@@ -320,4 +337,4 @@ </box>

<u>
{padEnd("Model", 26)} {padStart("Spend", 9)} {padStart("Share", 6)} {padEnd("Tokens (↑In ↓Out)", 22)}{" "}
{padStart("Reqs", 6)} {padStart("Out/In", 6)}
{padEnd('Model', 24)} {padStart('Spend', 9)} {padStart('Share', 6)} {padEnd('Tokens (↑In ↓Out)', 22)} {padStart('Reqs', 6)}{' '}
{padStart('Out/In', 6)}
</u>

@@ -330,16 +347,9 @@ </strong>

function ModelRow(props: { model: ModelUsage; totalSpend: number; keyName: string; theme: TuiThemeCurrent }): JSX.Element {
const share = props.totalSpend > 0 ? formatPercent(props.model.spend / props.totalSpend) : "—"
const share = props.totalSpend > 0 ? formatPercent(props.model.spend / props.totalSpend) : '—'
return (
<text fg={props.theme.text}>
<a href={modelAnalyticsUrl(props.keyName, props.model.model)}>
{padEnd(shortModel(props.model.model, 25), 26)}
</a>{" "}
{padStart(formatUsd(props.model.spend), 9)}{" "}
{padStart(share, 6)}{" "}
{padEnd(
`${formatTokens(props.model.totalTokens)} ${formatTokenBreakdown(props.model.inputTokens, props.model.outputTokens)}`,
22,
)}{" "}
{padStart(formatTokens(props.model.requests), 6)}{" "}
{padStart(formatOutputInputRatio(props.model.inputTokens, props.model.outputTokens), 6)}
<a href={modelAnalyticsUrl(props.keyName, props.model.model)}>{padEnd(shortModel(props.model.model, 23), 24)}</a>{' '}
{padStart(formatUsd(props.model.spend), 9)} {padStart(share, 6)}{' '}
{padEnd(`${formatTokens(props.model.totalTokens)} ${formatTokenBreakdown(props.model.inputTokens, props.model.outputTokens)}`, 22)}{' '}
{padStart(formatTokens(props.model.requests), 6)} {padStart(formatOutputInputRatio(props.model.inputTokens, props.model.outputTokens), 6)}
</text>

@@ -351,10 +361,3 @@ )

return (
<box
flexDirection="row"
border
borderStyle="single"
borderColor={props.theme.textMuted}
paddingX={1}
alignItems="center"
>
<box flexDirection="row" border borderStyle="single" borderColor={props.theme.textMuted} paddingX={1} alignItems="center">
<box flexDirection="row" gap={1}>

@@ -361,0 +364,0 @@ <text fg={props.theme.text}>

@@ -16,3 +16,3 @@ /** Shared formatting helpers for the Requesty widget and dialog. */

export function formatLimit(limit: number): string {
return limit > 0 ? formatUsd(limit) : "unlimited"
return limit > 0 ? formatUsd(limit) : 'unlimited'
}

@@ -32,3 +32,3 @@

/**
* Compact input/output token breakdown, e.g. "(↑1.0M ↓200k)".
* Compact input/output token breakdown, e.g. " (↑1.0M ↓200k)".
* ↑ = input tokens (into the model), ↓ = output tokens (from the model).

@@ -41,2 +41,10 @@ */

/**
* Inline input/output token breakdown without parentheses, e.g. "↑1.0M ↓200k".
* ↑ = input tokens (into the model), ↓ = output tokens (from the model).
*/
export function formatTokenInline(inputTokens: number, outputTokens: number): string {
return `↑${formatTokens(inputTokens)} ↓${formatTokens(outputTokens)}`
}
/**
* Output/input token ratio as a 2-decimal string, e.g. "0.37".

@@ -46,3 +54,3 @@ * Returns "—" when input is 0 (avoids division by zero).

export function formatOutputInputRatio(inputTokens: number, outputTokens: number): string {
if (inputTokens <= 0) return "—"
if (inputTokens <= 0) return '—'
return (outputTokens / inputTokens).toFixed(2)

@@ -52,4 +60,4 @@ }

const BAR_WIDTH = 16
const BAR_FILLED = "▓"
const BAR_EMPTY = "░"
const BAR_FILLED = '▓'
const BAR_EMPTY = '░'

@@ -64,6 +72,6 @@ export function renderBar(ratio: number, width = BAR_WIDTH): string {

export function shortModel(model: string, maxLength: number): string {
const slash = model.indexOf("/")
const slash = model.indexOf('/')
const short = slash >= 0 && slash < model.length - 1 ? model.slice(slash + 1) : model
if (short.length <= maxLength) return short
return short.slice(0, Math.max(1, maxLength - 1)) + "…"
return short.slice(0, Math.max(1, maxLength - 1)) + '…'
}

@@ -74,12 +82,12 @@

return date
.toLocaleString("sv-SE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
.toLocaleString('sv-SE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
.replace("T", " ")
.replace('T', ' ')
}

@@ -139,3 +147,3 @@

export type Pace = "under" | "on" | "over"
export type Pace = 'under' | 'on' | 'over'

@@ -154,5 +162,5 @@ /** Spend-ratio vs time-elapsed ratio within this many percentage points is "on pace". */

const spendRatio = spend / limit
if (spendRatio - timeRatio > PACE_TOLERANCE) return "over"
if (timeRatio - spendRatio > PACE_TOLERANCE) return "under"
return "on"
if (spendRatio - timeRatio > PACE_TOLERANCE) return 'over'
if (timeRatio - spendRatio > PACE_TOLERANCE) return 'under'
return 'on'
}

@@ -162,6 +170,6 @@

export function paceMarker(pace: Pace | undefined): string {
if (pace === "over") return "↑"
if (pace === "under") return "↓"
if (pace === "on") return "→"
return ""
if (pace === 'over') return '↑'
if (pace === 'under') return '↓'
if (pace === 'on') return '→'
return ''
}

@@ -194,6 +202,4 @@

const parts = formatProjectionParts(spend, limit, date)
if (!parts) return ""
return parts.arrow
? `~${formatUsd(parts.projected)} EOM ${parts.arrow}`
: `~${formatUsd(parts.projected)} EOM`
if (!parts) return ''
return parts.arrow ? `~${formatUsd(parts.projected)} EOM ${parts.arrow}` : `~${formatUsd(parts.projected)} EOM`
}

@@ -216,4 +222,4 @@

const pct = Math.round(((projected - lastMonthSpend) / lastMonthSpend) * 100)
const arrow = pct > 0 ? "▲" : pct < 0 ? "▼" : "→"
const sign = pct > 0 ? "+" : ""
const arrow = pct > 0 ? '▲' : pct < 0 ? '▼' : '→'
const sign = pct > 0 ? '+' : ''
return { arrow, sign, pct }

@@ -229,3 +235,3 @@ }

const parts = formatMonthDeltaParts(currentSpend, lastMonthSpend, date)
if (!parts) return ""
if (!parts) return ''
return `${parts.arrow} ${parts.sign}${parts.pct}% (${formatUsd(lastMonthSpend)} last month)`

@@ -245,3 +251,3 @@ }

/** Severity of budget usage, used to color the progress bar. */
export type SpendSeverity = "ok" | "warning" | "critical"
export type SpendSeverity = 'ok' | 'warning' | 'critical'

@@ -263,5 +269,5 @@ /** Spend/limit ratios at which the bar turns yellow (warning) and red (error). */

export function spendSeverity(ratio: number, thresholds: SpendThresholds = DEFAULT_THRESHOLDS): SpendSeverity {
if (ratio >= thresholds.error) return "critical"
if (ratio >= thresholds.warning) return "warning"
return "ok"
if (ratio >= thresholds.error) return 'critical'
if (ratio >= thresholds.warning) return 'warning'
return 'ok'
}

@@ -271,3 +277,3 @@

export function normalizeThreshold(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined
return value > 1 ? value / 100 : value

@@ -283,3 +289,3 @@ }

warning: normalizeThreshold(warning) ?? DEFAULT_THRESHOLDS.warning,
error: normalizeThreshold(error) ?? DEFAULT_THRESHOLDS.error,
error: normalizeThreshold(error) ?? DEFAULT_THRESHOLDS.error
}

@@ -292,3 +298,3 @@ if (thresholds.warning >= thresholds.error) return { ...DEFAULT_THRESHOLDS }

export function padEnd(value: string, width: number): string {
return value.length >= width ? value : value + " ".repeat(width - value.length)
return value.length >= width ? value : value + ' '.repeat(width - value.length)
}

@@ -298,3 +304,3 @@

export function padStart(value: string, width: number): string {
return value.length >= width ? value : " ".repeat(width - value.length) + value
return value.length >= width ? value : ' '.repeat(width - value.length) + value
}

@@ -310,6 +316,6 @@

/** Map a spend severity to the matching theme color. Preserves the theme's color type. */
export function severityColor<T extends SeverityTheme>(severity: SpendSeverity, theme: T): T["error"] {
if (severity === "critical") return theme.error
if (severity === "warning") return theme.warning as T["error"]
return theme.success as T["error"]
export function severityColor<T extends SeverityTheme>(severity: SpendSeverity, theme: T): T['error'] {
if (severity === 'critical') return theme.error
if (severity === 'warning') return theme.warning as T['error']
return theme.success as T['error']
}

@@ -12,5 +12,3 @@ /**

export type KeyResult =
| { ok: true; apiKey: string; source: string }
| { ok: false; reason: string }
export type KeyResult = { ok: true; apiKey: string; source: string } | { ok: false; reason: string }

@@ -32,3 +30,3 @@ const ENV_INTERPOLATION = /^\{env:([^}]+)\}$/

function resolveValue(raw: unknown): string | undefined {
if (typeof raw !== "string" || raw.trim().length === 0) return undefined
if (typeof raw !== 'string' || raw.trim().length === 0) return undefined
const match = ENV_INTERPOLATION.exec(raw.trim())

@@ -43,5 +41,5 @@ if (match) {

function isRequestyProvider(provider: ProviderConfig, name: string): boolean {
if (name === "requesty") return true
if (name === 'requesty') return true
const baseURL = provider.options?.baseURL
if (typeof baseURL !== "string") return false
if (typeof baseURL !== 'string') return false
try {

@@ -58,3 +56,3 @@ return REQUESTY_HOST.test(new URL(baseURL).hostname)

// Prefer the canonical provider id, then any custom Requesty provider.
const names = Object.keys(providers).sort((a, b) => (a === "requesty" ? -1 : b === "requesty" ? 1 : a.localeCompare(b)))
const names = Object.keys(providers).sort((a, b) => (a === 'requesty' ? -1 : b === 'requesty' ? 1 : a.localeCompare(b)))
for (const name of names) {

@@ -77,4 +75,4 @@ const provider = providers[name]

ok: false,
reason: `No Requesty API key found. Add provider.requesty.options.apiKey to opencode.json.`,
reason: `No Requesty API key found. Add provider.requesty.options.apiKey to opencode.json.`
}
}

@@ -1,2 +0,2 @@

import { resolveThresholds, type SpendThresholds } from "./format"
import { resolveThresholds, type SpendThresholds } from './format'

@@ -15,2 +15,4 @@ const DEFAULT_REFRESH_INTERVAL_MS = 5 * 60 * 1000

maxModels: number
showTokens: boolean
showKeyName: boolean
order: number

@@ -22,3 +24,8 @@ }

budgetIndicator: boolean
dailySpend: boolean
todaySpend: boolean
dailyAvg: boolean
avg7d: boolean
avg30d: boolean
showTokens: boolean
showKeyName: boolean
monthlyProjection: boolean

@@ -28,2 +35,6 @@ order: number

export type DialogSettings = {
showKeyName: boolean
}
export type PluginSettings = {

@@ -34,6 +45,7 @@ refreshIntervalMs: number

prompt: PromptSettings
dialog: DialogSettings
}
function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return fallback
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return fallback
if (value < min) return fallback

@@ -45,3 +57,3 @@ if (value > max) return fallback

function parseOrder(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_ORDER
if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_ORDER
return value

@@ -51,7 +63,10 @@ }

function readSidebarSettings(raw: unknown): SidebarSettings {
const obj = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {}
const obj = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
return {
enabled: typeof obj.enabled === "boolean" ? obj.enabled : true,
maxModels: typeof obj.maxModels === "number" && obj.maxModels >= MIN_MAX_MODELS ? Math.min(Math.floor(obj.maxModels), MAX_MAX_MODELS) : DEFAULT_MAX_MODELS,
order: parseOrder(obj.order),
enabled: typeof obj.enabled === 'boolean' ? obj.enabled : true,
maxModels:
typeof obj.maxModels === 'number' && obj.maxModels >= MIN_MAX_MODELS ? Math.min(Math.floor(obj.maxModels), MAX_MAX_MODELS) : DEFAULT_MAX_MODELS,
showTokens: typeof obj.showTokens === 'boolean' ? obj.showTokens : true,
showKeyName: typeof obj.showKeyName === 'boolean' ? obj.showKeyName : false,
order: parseOrder(obj.order)
}

@@ -66,14 +81,27 @@ }

prompt: readPromptSettings(options?.prompt),
dialog: readDialogSettings(options?.dialog)
}
}
function readDialogSettings(raw: unknown): DialogSettings {
const obj = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
return {
showKeyName: typeof obj.showKeyName === 'boolean' ? obj.showKeyName : false
}
}
function readPromptSettings(raw: unknown): PromptSettings {
const obj = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {}
const obj = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
return {
enabled: typeof obj.enabled === "boolean" ? obj.enabled : true,
budgetIndicator: typeof obj.budgetIndicator === "boolean" ? obj.budgetIndicator : true,
dailySpend: typeof obj.dailySpend === "boolean" ? obj.dailySpend : true,
monthlyProjection: typeof obj.monthlyProjection === "boolean" ? obj.monthlyProjection : true,
order: parseOrder(obj.order),
enabled: typeof obj.enabled === 'boolean' ? obj.enabled : true,
budgetIndicator: typeof obj.budgetIndicator === 'boolean' ? obj.budgetIndicator : true,
todaySpend: typeof obj.todaySpend === 'boolean' ? obj.todaySpend : true,
dailyAvg: typeof obj.dailyAvg === 'boolean' ? obj.dailyAvg : false,
avg7d: typeof obj['7dAvg'] === 'boolean' ? obj['7dAvg'] : false,
avg30d: typeof obj['30dAvg'] === 'boolean' ? obj['30dAvg'] : false,
showTokens: typeof obj.showTokens === 'boolean' ? obj.showTokens : true,
showKeyName: typeof obj.showKeyName === 'boolean' ? obj.showKeyName : false,
monthlyProjection: typeof obj.monthlyProjection === 'boolean' ? obj.monthlyProjection : true,
order: parseOrder(obj.order)
}
}

@@ -1,22 +0,23 @@

import { createSignal } from "solid-js"
import { createSignal } from 'solid-js'
import {
aggregateByModel,
avgSpendLastNDays,
avgTokensLastNDays,
endOfLastMonth,
filterUsageByMonth,
getApiKeySelf,
getUsageSelf,
spendForDay,
startOfCurrentMonth,
startOfLastMonth,
startOfRollingWindow,
tokensForDay,
totalSpendFromUsage,
type ApiKeyInfo,
type ModelUsage,
type UsageResponse,
} from "./api"
type TokenBreakdown,
type UsageResponse
} from './api'
import { dailyAverage } from './format'
export type RefreshState =
| { status: "idle" }
| { status: "loading" }
| { status: "ready"; fetchedAt: Date }
| { status: "error"; message: string }
export type RefreshState = { status: 'idle' } | { status: 'loading' } | { status: 'ready'; fetchedAt: Date } | { status: 'error'; message: string }

@@ -28,4 +29,9 @@ export type RequestyData = {

todaySpend: number
dailyAvg: number
avg7d: number
avg30d: number
todayTokens: TokenBreakdown
dailyAvgTokens: TokenBreakdown
avg7dTokens: TokenBreakdown
avg30dTokens: TokenBreakdown
lastMonthSpend: number

@@ -54,3 +60,3 @@ }

export function createRequestyStore(options: RequestyStoreOptions): RequestyStore {
const [state, setState] = createSignal<RefreshState>({ status: "idle" })
const [state, setState] = createSignal<RefreshState>({ status: 'idle' })
const [data, setData] = createSignal<RequestyData | undefined>(undefined)

@@ -70,3 +76,3 @@ const [version, setVersion] = createSignal(0)

}
setState((previous) => (previous.status === "ready" ? previous : { status: "loading" }))
setState((previous) => (previous.status === 'ready' ? previous : { status: 'loading' }))
inFlight = (async () => {

@@ -76,28 +82,43 @@ try {

const usage = await fetchUsage(options.apiKey, {
start: startOfCurrentMonth(),
groupBy: ["model_used"],
resolution: "day" as const,
start: startOfRollingWindow(30),
groupBy: ['model_used'],
resolution: 'day' as const
})
const models = aggregateByModel(usage)
const monthSpendFromUsage = models.reduce((total, model) => total + model.spend, 0)
const todaySpend = spendForDay(usage)
const avg7d = avgSpendLastNDays(usage, 7)
const avg30d = avgSpendLastNDays(usage, 30)
const currentMonthUsage = filterUsageByMonth(usage)
const aggregated = aggregateByModel(currentMonthUsage)
let lastMonthSpend = 0
try {
const lastMonthUsage = await fetchUsage(options.apiKey, {
const [_, lastMonthUsage] = await Promise.all([
Promise.resolve(), // Keep main flow clean
fetchUsage(options.apiKey, {
start: startOfLastMonth(),
end: endOfLastMonth(),
resolution: "day",
})
lastMonthSpend = totalSpendFromUsage(lastMonthUsage)
} catch {
// last month data is non-critical; continue without it
}
setData({ keyInfo, models, monthSpendFromUsage, todaySpend, avg7d, avg30d, lastMonthSpend })
setState({ status: "ready", fetchedAt: new Date() })
resolution: 'day'
}).catch(() => undefined)
])
if (lastMonthUsage) lastMonthSpend = totalSpendFromUsage(lastMonthUsage)
setData({
keyInfo,
models: aggregated.models,
monthSpendFromUsage: aggregated.spend,
todaySpend: spendForDay(usage),
dailyAvg: dailyAverage(keyInfo.monthly_spend),
avg7d: avgSpendLastNDays(usage, 7),
avg30d: avgSpendLastNDays(usage, 30),
todayTokens: tokensForDay(usage),
dailyAvgTokens: {
input: dailyAverage(aggregated.inputTokens),
output: dailyAverage(aggregated.outputTokens),
total: dailyAverage(aggregated.totalTokens)
},
avg7dTokens: avgTokensLastNDays(usage, 7),
avg30dTokens: avgTokensLastNDays(usage, 30),
lastMonthSpend
})
setState({ status: 'ready', fetchedAt: new Date() })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
options.onError?.(message)
setState({ status: "error", message })
setState({ status: 'error', message })
} finally {

@@ -119,3 +140,3 @@ inFlight = undefined

version,
bumpVersion: () => setVersion((v) => v + 1),
bumpVersion: () => setVersion((v) => v + 1)
}

@@ -122,0 +143,0 @@ }

/** @jsxImportSource @opentui/solid */
import type { TuiPluginModule } from "@opencode-ai/plugin/tui"
import { detectApiKey } from "./key"
import { createRequestyStore, type RequestyStore } from "./state"
import { RequestySidebarWidget, RequestyPromptIndicator } from "./widget"
import { RequestyDetailDialog } from "./dialog"
import { readSettings } from "./settings"
import type { TuiPluginModule } from '@opencode-ai/plugin/tui'
import { detectApiKey } from './key'
import { createRequestyStore, type RequestyStore } from './state'
import { RequestySidebarWidget, RequestyPromptIndicator } from './widget'
import { RequestyDetailDialog } from './dialog'
import { readSettings } from './settings'
const PLUGIN_ID = "opencode-requesty-sidebar"
const COMMAND_OPEN = "requesty.open"
const COMMAND_REFRESH = "requesty.refresh"
const PLUGIN_ID = 'opencode-requesty-sidebar'
const COMMAND_OPEN = 'requesty.open'
const COMMAND_REFRESH = 'requesty.refresh'

@@ -34,4 +34,4 @@ const plugin: TuiPluginModule = {

)
},
},
}
}
})

@@ -45,4 +45,4 @@ }

onError: (message) => {
api.ui.toast({ variant: "error", title: "Requesty", message })
},
api.ui.toast({ variant: 'error', title: 'Requesty', message })
}
})

@@ -66,6 +66,8 @@

thresholds={settings.thresholds}
showTokens={settings.sidebar.showTokens}
showKeyName={settings.sidebar.showKeyName}
/>
)
},
},
}
}
})

@@ -80,5 +82,20 @@ }

session_prompt_right(ctx, slotProps) {
return <RequestyPromptIndicator store={store} api={api} sessionID={slotProps.session_id} theme={ctx.theme.current} thresholds={settings.thresholds} dailySpend={settings.prompt.dailySpend} monthlyProjection={settings.prompt.monthlyProjection} />
},
},
return (
<RequestyPromptIndicator
store={store}
api={api}
sessionID={slotProps.session_id}
theme={ctx.theme.current}
thresholds={settings.thresholds}
todaySpend={settings.prompt.todaySpend}
dailyAvg={settings.prompt.dailyAvg}
avg7d={settings.prompt.avg7d}
avg30d={settings.prompt.avg30d}
showTokens={settings.prompt.showTokens}
showKeyName={settings.prompt.showKeyName}
monthlyProjection={settings.prompt.monthlyProjection}
/>
)
}
}
})

@@ -94,2 +111,3 @@ }

thresholds={settings.thresholds}
showKeyName={settings.dialog.showKeyName}
onClose={() => api.ui.dialog.clear()}

@@ -99,6 +117,14 @@ onRefresh={() => void store.refresh()}

))
api.ui.dialog.setSize("large")
api.ui.dialog.setSize('large')
void store.refresh()
}
let debounceTimer: ReturnType<typeof setTimeout> | undefined
const debouncedRefresh = () => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
void store.refresh()
}, 2000)
}
// Commands (command palette + slash command)

@@ -109,22 +135,22 @@ api.keymap.registerLayer({

name: COMMAND_OPEN,
title: "Requesty: show usage",
desc: "Show Requesty.ai budget, spend and per-model costs",
category: "Requesty",
namespace: "palette",
slashName: "requesty",
title: 'Requesty: show usage',
desc: 'Show Requesty.ai budget, spend and per-model costs',
category: 'Requesty',
namespace: 'palette',
slashName: 'requesty',
run: () => {
openDialog()
},
}
},
{
name: COMMAND_REFRESH,
title: "Requesty: refresh usage",
desc: "Refresh Requesty.ai usage data",
category: "Requesty",
namespace: "palette",
title: 'Requesty: refresh usage',
desc: 'Refresh Requesty.ai usage data',
category: 'Requesty',
namespace: 'palette',
run: () => {
void store.refresh()
},
},
],
}
}
]
})

@@ -139,13 +165,13 @@

const unsubSessionCreated = api.event.on("session.created", () => {
const unsubSessionCreated = api.event.on('session.created', () => {
void store.refresh()
})
const unsubSessionIdle = api.event.on("session.idle", () => {
const unsubSessionIdle = api.event.on('session.idle', () => {
void store.refresh()
})
const unsubMessage = api.event.on("message.updated", () => {
const unsubMessage = api.event.on('message.updated', () => {
store.bumpVersion()
void store.refresh()
debouncedRefresh()
})

@@ -155,2 +181,3 @@

clearInterval(interval)
clearTimeout(debounceTimer)
unsubSessionCreated()

@@ -160,5 +187,5 @@ unsubSessionIdle()

})
},
}
}
export default plugin
/** @jsxImportSource @opentui/solid */
import { Show, For, createMemo, createSignal, type JSX } from "solid-js"
import type { TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
import type { RequestyStore } from "./state"
import { formatLimit, formatPercent, formatProjectionParts, formatTokenBreakdown, formatTokens, formatUsd, analyticsUrl, isProjectionOverLimit, padEnd, padStart, renderBar, shortModel, spendRatio, spendSeverity, severityColor, type Pace, type SpendThresholds } from "./format"
import { Show, For, createMemo, createSignal, type JSX } from 'solid-js'
import type { TuiPluginApi, TuiThemeCurrent } from '@opencode-ai/plugin/tui'
import type { RequestyStore } from './state'
import {
formatLimit,
formatPercent,
formatProjectionParts,
formatTokenBreakdown,
formatTokenInline,
formatTokens,
formatUsd,
analyticsUrl,
isProjectionOverLimit,
padEnd,
padStart,
renderBar,
shortModel,
spendRatio,
spendSeverity,
severityColor,
type Pace,
type SpendThresholds
} from './format'

@@ -16,2 +35,6 @@ export type WidgetProps = {

thresholds: SpendThresholds
/** Show input/output token breakdown alongside spend in the averages block. */
showTokens: boolean
/** Show the API key nickname. */
showKeyName: boolean
}

@@ -25,3 +48,8 @@

thresholds: SpendThresholds
dailySpend: boolean
todaySpend: boolean
dailyAvg: boolean
avg7d: boolean
avg30d: boolean
showTokens: boolean
showKeyName: boolean
monthlyProjection: boolean

@@ -31,4 +59,4 @@ }

function paceColor(pace: Pace | undefined, theme: TuiThemeCurrent) {
if (pace === "over") return theme.error
if (pace === "under") return theme.success
if (pace === 'over') return theme.error
if (pace === 'under') return theme.success
return theme.textMuted

@@ -43,3 +71,3 @@ }

messagesLength: props.api.state.session.messages(props.sessionID).length,
version: props.store.version(),
version: props.store.version()
}))

@@ -50,8 +78,5 @@

<text fg={theme().text}>
<Show
when={snapshot().data}
fallback={<strong>Requesty</strong>}
>
<Show when={snapshot().data} fallback={<strong>Requesty</strong>}>
<a href={analyticsUrl(snapshot().data!.keyInfo.name)}>
<strong>Requesty ({snapshot().data!.keyInfo.name})</strong>
<strong>Requesty{props.showKeyName ? ` (${snapshot().data!.keyInfo.name})` : ''}</strong>
</a>

@@ -62,8 +87,17 @@ </Show>

<Show
when={props.store.state().status !== "error"}
when={props.store.state().status !== 'error'}
fallback={
<box flexDirection="column">
<text fg={theme().error}>Requesty: {props.store.state().status === "error" ? (props.store.state() as { message: string }).message : ""}</text>
<text fg={theme().error}>
Requesty: {props.store.state().status === 'error' ? (props.store.state() as { message: string }).message : ''}
</text>
<Show when={snapshot().data}>
<Snapshot store={props.store} theme={theme()} maxModels={props.maxModels} thresholds={props.thresholds} stale />
<Snapshot
store={props.store}
theme={theme()}
maxModels={props.maxModels}
thresholds={props.thresholds}
showTokens={props.showTokens}
stale
/>
</Show>

@@ -77,7 +111,7 @@ </box>

<text fg={theme().textMuted}>
{props.store.state().status === "loading" ? "Loading Requesty usage…" : "Requesty: waiting for first refresh…"}
{props.store.state().status === 'loading' ? 'Loading Requesty usage…' : 'Requesty: waiting for first refresh…'}
</text>
}
>
<Snapshot store={props.store} theme={theme()} maxModels={props.maxModels} thresholds={props.thresholds} />
<Snapshot store={props.store} theme={theme()} maxModels={props.maxModels} thresholds={props.thresholds} showTokens={props.showTokens} />
</Show>

@@ -95,2 +129,3 @@ </Show>

thresholds: SpendThresholds
showTokens: boolean
stale?: boolean

@@ -114,34 +149,68 @@ }

<box flexDirection="row" justifyContent="space-between" alignItems="center">
<text fg={severityColor(spendSeverity(ratio(), props.thresholds), props.theme)}>
{renderBar(ratio(), 24)}
</text>
<text fg={severityColor(spendSeverity(ratio(), props.thresholds), props.theme)}>
{formatPercent(ratio())}
</text>
<text fg={severityColor(spendSeverity(ratio(), props.thresholds), props.theme)}>{renderBar(ratio(), 24)}</text>
<text fg={severityColor(spendSeverity(ratio(), props.thresholds), props.theme)}>{formatPercent(ratio())}</text>
</box>
</Show>
<box flexDirection="row" justifyContent="space-between">
<text fg={props.theme.textMuted}>{formatUsd(spend())}</text>
<text fg={props.theme.textMuted}>·</text>
<text fg={props.theme.textMuted}>{formatLimit(limit())}</text>
<Show when={projectionParts()}>
<text fg={props.theme.textMuted}>·</text>
<text fg={projectionOverLimit() ? props.theme.error : props.theme.textMuted}>
~{formatUsd(projectionParts()!.projected)} EOM{" "}
<Show when={projectionParts()!.arrow}>
<span style={{ fg: paceColor(projectionParts()!.pace, props.theme) }}>{projectionParts()!.arrow}</span>
<text fg={props.theme.textMuted}>
{formatUsd(spend())} / {formatLimit(limit())}
</text>
<Show when={projectionParts() || props.stale}>
<box flexDirection="row">
<Show when={projectionParts()}>
<text fg={projectionOverLimit() ? props.theme.error : props.theme.textMuted}>
~{formatUsd(projectionParts()!.projected)} EOM{' '}
<Show when={projectionParts()!.arrow}>
<span style={{ fg: paceColor(projectionParts()!.pace, props.theme) }}>{projectionParts()!.arrow}</span>
</Show>
</text>
</Show>
</text>
<Show when={props.stale}>
<text fg={props.theme.textMuted}> (stale)</text>
</Show>
</box>
</Show>
<Show when={props.stale}>
<text fg={props.theme.textMuted}>(stale)</text>
</Show>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={props.theme.textMuted}>Today {formatUsd(data().todaySpend)}</text>
<text fg={props.theme.textMuted}>·</text>
<text fg={props.theme.textMuted}>7d {formatUsd(data().avg7d)}</text>
<text fg={props.theme.textMuted}>·</text>
<text fg={props.theme.textMuted}>30d {formatUsd(data().avg30d)}</text>
</box>
<Show
when={props.showTokens}
fallback={
<box flexDirection="column" gap={0}>
<box flexDirection="row" justifyContent="space-between">
<text fg={props.theme.textMuted}>Today {formatUsd(data().todaySpend)}</text>
<text fg={props.theme.textMuted}>7d {formatUsd(data().avg7d)}</text>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={props.theme.textMuted}>Daily {formatUsd(data().dailyAvg)}</text>
<text fg={props.theme.textMuted}>30d {formatUsd(data().avg30d)}</text>
</box>
</box>
}
>
<box flexDirection="column" gap={0}>
<box flexDirection="row" justifyContent="space-between">
<text fg={props.theme.textMuted}>
{padEnd('Today', 9)} {padStart(formatUsd(data().todaySpend), 10)}
</text>
<text fg={props.theme.textMuted}>{formatTokenInline(data().todayTokens.input, data().todayTokens.output)}</text>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={props.theme.textMuted}>
{padEnd('Daily avg', 9)} {padStart(formatUsd(data().dailyAvg), 10)}
</text>
<text fg={props.theme.textMuted}>{formatTokenInline(data().dailyAvgTokens.input, data().dailyAvgTokens.output)}</text>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={props.theme.textMuted}>
{padEnd('7d avg', 9)} {padStart(formatUsd(data().avg7d), 10)}
</text>
<text fg={props.theme.textMuted}>{formatTokenInline(data().avg7dTokens.input, data().avg7dTokens.output)}</text>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={props.theme.textMuted}>
{padEnd('30d avg', 9)} {padStart(formatUsd(data().avg30d), 10)}
</text>
<text fg={props.theme.textMuted}>{formatTokenInline(data().avg30dTokens.input, data().avg30dTokens.output)}</text>
</box>
</box>
</Show>
</box>

@@ -158,3 +227,3 @@ <text> </text>

<text fg={props.theme.text}>
<strong>{expanded() ? "▼" : "▶"} Top Models (Current Month)</strong>
<strong>{expanded() ? '▼' : '▶'} Top Models (Current Month)</strong>
</text>

@@ -171,3 +240,3 @@ </box>

<text fg={props.theme.textMuted}>
{" "}
{' '}
{formatTokens(model.totalTokens)} {formatTokenBreakdown(model.inputTokens, model.outputTokens)}

@@ -198,6 +267,4 @@ </text>

const ratio = spendRatio(spend, limit)
const name = d?.keyInfo.name ?? ""
const color = !d || limit <= 0
? props.theme.textMuted
: severityColor(spendSeverity(ratio, props.thresholds), props.theme)
const name = d?.keyInfo.name ?? ''
const color = !d || limit <= 0 ? props.theme.textMuted : severityColor(spendSeverity(ratio, props.thresholds), props.theme)
const projectionParts = formatProjectionParts(spend, limit)

@@ -207,15 +274,29 @@ const projectionOverLimit = isProjectionOverLimit(spend, limit)

const parts: { text: string; color?: unknown; href?: string }[] = []
if (d && props.dailySpend) {
parts.push({ text: `${formatUsd(d.todaySpend)} `, color: props.theme.textMuted })
if (d) {
const averages: string[] = []
if (props.todaySpend) {
let label = `T ${formatUsd(d.todaySpend)}`
if (props.showTokens) {
label += ` ${formatTokenInline(d.todayTokens.input, d.todayTokens.output)}`
}
averages.push(label)
}
if (props.dailyAvg) averages.push(`D ${formatUsd(d.dailyAvg)}`)
if (props.avg7d) averages.push(`7d ${formatUsd(d.avg7d)}`)
if (props.avg30d) averages.push(`30d ${formatUsd(d.avg30d)}`)
if (averages.length > 0) {
parts.push({ text: `${averages.join(' · ')} `, color: props.theme.textMuted })
}
}
if (status === "loading" && !d) {
parts.push({ text: "Requesty …", color: props.theme.textMuted })
} else if (status === "error" && !d) {
parts.push({ text: "Requesty !", color: props.theme.textMuted })
if (status === 'loading' && !d) {
parts.push({ text: 'Requesty …', color: props.theme.textMuted })
} else if (status === 'error' && !d) {
parts.push({ text: 'Requesty !', color: props.theme.textMuted })
} else if (!d) {
parts.push({ text: "Requesty …", color: props.theme.textMuted })
parts.push({ text: 'Requesty …', color: props.theme.textMuted })
} else {
const label = limit > 0
? `${formatUsd(spend)}/${formatUsd(limit)} ${formatPercent(ratio)} (${name})`
: `${formatUsd(spend)}/unlimited (${name})`
const label =
limit > 0
? `${formatUsd(spend)}/${formatUsd(limit)} ${formatPercent(ratio)}${props.showKeyName ? ` (${name})` : ''}`
: `${formatUsd(spend)}/unlimited${props.showKeyName ? ` (${name})` : ''}`
parts.push({ text: label, color, href: analyticsUrl(name) })

@@ -238,3 +319,5 @@ }

<Show when={seg.href} fallback={<span style={{ fg: seg.color }}>{seg.text}</span>}>
<a href={seg.href!} style={{ fg: seg.color }}>{seg.text}</a>
<a href={seg.href!} style={{ fg: seg.color }}>
{seg.text}
</a>
</Show>

@@ -241,0 +324,0 @@ )}

{
"name": "@christiangalsterer/opencode-requesty-plugin",
"version": "1.0.0",
"version": "1.1.0",
"author": {

@@ -9,3 +9,11 @@ "name": "Christian Galsterer"

"description": "opencode TUI plugin: shows Requesty.ai monthly budget, current spend, and per-model cost distribution in the session sidebar, in the session prompt and with a detail dialog via the /requesty command",
"keywords": ["opencode", "opencode-plugin", "opencode-tui", "opencode-tui-plugin", "requesty", "tui", "cost-tracking"],
"keywords": [
"opencode",
"opencode-plugin",
"opencode-tui",
"opencode-tui-plugin",
"requesty",
"tui",
"cost-tracking"
],
"repository": {

@@ -28,11 +36,12 @@ "type": "git",

"devDependencies": {
"@opencode-ai/plugin": "^1.18.16",
"@opencode-ai/sdk": "^1.18.16",
"@opentui/core": "^0.5.3",
"@opentui/keymap": "^0.5.3",
"@opentui/solid": "^0.5.3",
"@types/bun": "^1.3.14",
"@types/node": "^24.0.0",
"solid-js": "^1.9.14",
"typescript": "^5.9.3"
"@opencode-ai/plugin": "1.18.21",
"@opencode-ai/sdk": "1.18.21",
"@opentui/core": "0.5.6",
"@opentui/keymap": "0.5.6",
"@opentui/solid": "0.5.6",
"@types/bun": "1.4.0",
"@types/node": "26.2.0",
"prettier": "3.9.6",
"solid-js": "1.9.15",
"typescript": "7.0.2"
},

@@ -47,6 +56,10 @@ "peerDependencies": {

},
"files": ["dist", "README.md", "LICENSE"],
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"ci": "bun run clean && bun install && bun run typecheck && bun test && bun run build",
"build": "mkdir -p ./dist && cp -r src/* dist/",
"ci": "bun run clean && bun install && bun run format && bun test && bun run build",
"build": "bun run typecheck && mkdir -p ./dist && cp -r src/* dist/",
"clean": "bun run clean:dist && bun run clean:deps",

@@ -56,6 +69,11 @@ "clean:dist": "rm -rf ./dist && mkdir -p ./dist",

"deps": "bun install",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"typecheck": "tsc --noEmit",
"test": "bun test",
"test:coverage": "bun test --coverage"
"test": "bun run typecheck && bun test",
"test:coverage": "bun run typecheck && bun test --coverage",
"release:prepare": "bun run clean && bun install && bun run format && bun test",
"publish-npm": "bun run ci && bun publish --access=public",
"publish-npm:dry-run": "bun run ci && bun publish --access=public --dry-run"
}
}
+97
-22

@@ -5,2 +5,4 @@ [![GitHub Actions CI Status](https://github.com/christiangalsterer/opencode-requesty-plugin/actions/workflows/ci.yaml/badge.svg)](https://github.com/christiangalsterer/opencode-requesty-plugin/actions/workflows/ci.yaml)

[![Known Vulnerabilities](https://snyk.io/test/github/christiangalsterer/opencode-requesty-plugin/badge.svg)](https://github.com/christiangalsterer/opencode-requesty-plugin/security/advisories)
[![Socket Badge](https://badge.socket.dev/npm/package/@christiangalsterer/opencode-requesty-plugin)](https://socket.dev/npm/package/@christiangalsterer/opencode-requesty-plugin)
[![renovate](https://img.shields.io/badge/renovate-enabled-brightgreen.svg)](https://developer.mend.io/github/christiangalsterer/opencode-requesty-plugin)
[![npm downloads](https://img.shields.io/npm/dt/@christiangalsterer/opencode-requesty-plugin.svg)](https://www.npmjs.com/package/@christiangalsterer/opencode-requesty-plugin)

@@ -11,3 +13,2 @@ [![npm version](https://img.shields.io/npm/v/@christiangalsterer/opencode-requesty-plugin.svg)](https://www.npmjs.com/package/@christiangalsterer/opencode-requesty-plugin?activeTab=versions)

[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org)
[![renovate](https://img.shields.io/badge/renovate-enabled-brightgreen.svg)](https://developer.mend.io/github/christiangalsterer/opencode-requesty-plugin)
[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier)

@@ -33,3 +34,4 @@ ![github stars](https://img.shields.io/github/stars/christiangalsterer/opencode-requesty-plugin.svg)

- Projected month-end spend at the current run rate (`~$X EOM`), with a pace marker: ↑ over pace, → on pace, ↓ under pace
- Daily spend trend: today · 7-day average · 30-day average
- Daily spend trend: today · daily average · 7-day average · 30-day average
- Optional input/output token breakdown for each spend metric (`sidebar.showTokens`)
- API key name in the header, linking to the Requesty analytics dashboard filtered by that key

@@ -40,15 +42,5 @@ - Top models for the current month (up to `sidebar.maxModels`), each with spend, total tokens, and input (↑) / output (↓) breakdown; click the header to collapse or expand the list

### Detail dialog
![Detail dialog](docs/images/detail-dialog.png)
Open the dialog with `/requesty` or by picking *Requesty: show usage* from the command palette for the full breakdown:
- KPI row: spent, limit, remaining, End of Month projection with a colored pace arrow, and last month's spend with a colored trend chevron
- *Budget Overview* card: wide progress bar, budget-health badge, days-to-exhaustion estimate based on your 7-day average, and today/7d/30d averages
- *Model Breakdown (Current Month)* card: per-model table with spend, share of total spend, tokens, request count, and output/input ratio
### Prompt indicator
![Prompt indicator](docs/images/prompt-indicator.png)
![Prompt indicator](docs/images/session-prompt.png)

@@ -63,2 +55,13 @@ A compact readout on the right side of the session prompt shows:

### Detail dialog
![Detail dialog](docs/images/dialog.png)
Open the dialog with `/requesty` from the command palette for the full breakdown:
- KPI row: spent, limit, remaining, End of Month projection with a colored pace arrow, and last month's spend with a colored trend chevron
- *Budget Overview* card: wide progress bar, budget-health badge, days-to-exhaustion estimate based on your 7-day average, and today/daily avg/7d/30d averages
- *Model Breakdown (Current Month)* card: per-model table with spend, share of total spend, tokens, request count, and output/input ratio
Data comes from the [Requesty Management API](https://docs.requesty.ai/api-reference/management-apis) (`apikey/self` + `apikey/self/usage` grouped by `model_used`, current calendar month).

@@ -87,9 +90,10 @@

"refreshIntervalMs": 300000,
"warningThreshold": 0.6,
"errorThreshold": 0.85
"sidebar": {
"enabled": true,
"maxModels": 5,
"showTokens": true,
"order": 50
},
"warningThreshold": 0.6,
"errorThreshold": 0.85
}
}

@@ -101,2 +105,20 @@ ]

Plugin options must be the second item in the nested plugin entry. The same format is used for local plugins; use the generated `dist/tui.tsx` file as the plugin path:
```json
{
"$schema": "https://opencode.ai/tui.json",
"plugin": [
[
"/absolute/path/to/opencode-requesty-sidebar-plugin/dist/tui.tsx",
{
"sidebar": { "showKeyName": true },
"prompt": { "showKeyName": true },
"dialog": { "showKeyName": true }
}
]
]
}
```
Restart opencode after changing the config — plugins are loaded at startup.

@@ -110,3 +132,13 @@

{
"plugin": ["file:///absolute/path/to/opencode-requesty-plugin/dist/tui.tsx"]
"$schema": "https://opencode.ai/tui.json",
"plugin": [
[
"file:///absolute/path/to/opencode-requesty-plugin/dist/tui.tsx",
{
"sidebar": { "showKeyName": true },
"prompt": { "showKeyName": true },
"dialog": { "showKeyName": true }
}
]
]
}

@@ -164,11 +196,33 @@ ```

| `sidebar.maxModels` | number | `5` | Number of models shown in the compact sidebar list |
| `sidebar.showTokens` | boolean | `true` | Show input/output token breakdown alongside spend in the sidebar averages block |
| `sidebar.showKeyName` | boolean | `false` | Show the API key nickname in the sidebar header |
| `sidebar.order` | number | `50` | Slot order for the sidebar widget; lower numbers appear first |
| `prompt.enabled` | boolean | `true` | Show the prompt widget |
| `prompt.budgetIndicator` | boolean | `true` | Show spend/limit readout on the right side of the session prompt |
| `prompt.dailySpend` | boolean | `true` | Show today's spend to the left of the budget indicator in the session prompt |
| `prompt.todaySpend` | boolean | `true` | Show today's spend (`T $X`) in the session prompt averages block |
| `prompt.dailyAvg` | boolean | `false` | Show the current month's daily average (`D $X`) in the prompt averages block |
| `prompt."7dAvg"` | boolean | `false` | Show the 7-day average (`7d $X`) in the prompt averages block |
| `prompt."30dAvg"` | boolean | `false` | Show the 30-day average (`30d $X`) in the prompt averages block |
| `prompt.showTokens` | boolean | `true` | Show today's input/output token breakdown (`↑X↓Y`) next to today's spend in the session prompt |
| `prompt.showKeyName` | boolean | `false` | Show the API key nickname in the session prompt readout |
| `prompt.monthlyProjection`| boolean | `true` | Show a month-end projection (`~$X EOM ↑`) in the session prompt, red when the estimated spend exceeds the budget |
| `prompt.order` | number | `50` | Slot order for the prompt indicator; lower numbers appear first |
| `dialog.showKeyName` | boolean | `false` | Show the API key nickname in the detail dialog title |
`warningThreshold` must be lower than `errorThreshold`; if the ordering is invalid, both fall back to the defaults (70%/90%). Values above `1` are treated as percents, e.g. `80` means 80%.
### Using Requesty with multiple API keys
If you utilize different API keys for various projects, it is highly recommended to enable `showKeyName` in your configuration. This allows you to easily identify which Requesty API key is currently active in the sidebar, session prompt, and detail dialog.
Example for enabling key identification:
```json
{
"sidebar": { "showKeyName": true },
"prompt": { "showKeyName": true },
"dialog": { "showKeyName": true }
}
```
### Complete configuration example

@@ -184,15 +238,25 @@

"refreshIntervalMs": 300000,
"warningThreshold": 0.7,
"errorThreshold": 0.9,
"sidebar": {
"enabled": true,
"maxModels": 5,
"showTokens": true,
"showKeyName": true,
"order": 50
},
"warningThreshold": 0.7,
"errorThreshold": 0.9,
"prompt": {
"enabled": true,
"budgetIndicator": true,
"dailySpend": true,
"todaySpend": true,
"dailyAvg": false,
"7dAvg": false,
"30dAvg": false,
"showTokens": true,
"showKeyName": true,
"monthlyProjection": true,
"order": 50
},
"dialog": {
"showKeyName": true
}

@@ -207,2 +271,12 @@ }

## Metrics
All amounts are in USD and dates are evaluated in UTC.
- **Today** — spend and tokens for the current calendar day.
- **Daily avg** — current month's total spend and tokens divided by the number of days elapsed so far this month.
- **7d avg** — average spend and tokens over the previous 7 completed calendar days (excluding today). Days without usage count as `$0` / `0` tokens. Uses a rolling window to ensure accuracy across month boundaries.
- **30d avg** — average spend and tokens over the previous 30 completed calendar days (excluding today). Days without usage count as `$0` / `0` tokens. Uses a rolling window to ensure accuracy across month boundaries.
- **End of Month projection** — current spend projected forward at the current daily run rate through the end of the month.
## Requirements

@@ -217,4 +291,5 @@

bun install
bun run format:fix
bun run typecheck
bun test
bun run test
bun run build

@@ -221,0 +296,0 @@ ```