@christiangalsterer/opencode-requesty-plugin
Advanced tools
+136
-50
@@ -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 | ||
| } | ||
| } |
+108
-105
| /** @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}> |
+49
-43
@@ -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'] | ||
| } |
+6
-8
@@ -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.` | ||
| } | ||
| } |
+42
-14
@@ -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) | ||
| } | ||
| } |
+52
-31
@@ -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 @@ } |
+64
-37
| /** @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 |
+144
-61
| /** @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 @@ )} |
+34
-16
| { | ||
| "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 @@ [](https://github.com/christiangalsterer/opencode-requesty-plugin/actions/workflows/ci.yaml) | ||
| [](https://github.com/christiangalsterer/opencode-requesty-plugin/security/advisories) | ||
| [](https://socket.dev/npm/package/@christiangalsterer/opencode-requesty-plugin) | ||
| [](https://developer.mend.io/github/christiangalsterer/opencode-requesty-plugin) | ||
| [](https://www.npmjs.com/package/@christiangalsterer/opencode-requesty-plugin) | ||
@@ -11,3 +13,2 @@ [](https://www.npmjs.com/package/@christiangalsterer/opencode-requesty-plugin?activeTab=versions) | ||
| [](https://conventionalcommits.org) | ||
| [](https://developer.mend.io/github/christiangalsterer/opencode-requesty-plugin) | ||
| [](https://github.com/prettier/prettier) | ||
@@ -33,3 +34,4 @@  | ||
| - 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 | ||
|  | ||
| 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 | ||
|  | ||
|  | ||
@@ -63,2 +55,13 @@ A compact readout on the right side of the session prompt shows: | ||
| ### Detail dialog | ||
|  | ||
| 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 @@ ``` |
79213
22.03%1616
17.1%291
34.72%10
11.11%