Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/app/src/components/prompt-input/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } fr
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { Identifier } from "@/utils/id"
import { trackProviderUsage } from "@/utils/provider-usage"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom"
Expand Down Expand Up @@ -51,6 +52,8 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ?
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")

export async function sendFollowupDraft(input: FollowupSendInput) {
trackProviderUsage(input.draft.sessionDirectory, input.draft.model.providerID, input.draft.sessionID)

const text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt)
const [, setStore] = input.globalSync.child(input.draft.sessionDirectory)
Expand Down Expand Up @@ -429,6 +432,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.onSubmit?.()

if (mode === "shell") {
trackProviderUsage(sessionDirectory, model.providerID, session.id)
clearInput()
client.session
.shell({
Expand All @@ -452,6 +456,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const commandName = cmdName.slice(1)
const customCommand = sync.data.command.find((c) => c.name === commandName)
if (customCommand) {
trackProviderUsage(sessionDirectory, model.providerID, session.id)
clearInput()
client.session
.command({
Expand Down
148 changes: 147 additions & 1 deletion packages/app/src/components/status-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Popover } from "@opencode-ai/ui/popover"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { useNavigate, useParams } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
Expand All @@ -14,6 +14,8 @@ import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useProviders } from "@/hooks/use-providers"
import { getProviderUsage, setProviderLimit } from "@/utils/provider-usage"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { DialogSelectServer } from "./dialog-select-server"

Expand Down Expand Up @@ -170,6 +172,8 @@ export function StatusPopover() {
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
const params = useParams()
const providers = useProviders()

const [shown, setShown] = createSignal(false)
const servers = createMemo(() => {
Expand All @@ -191,6 +195,17 @@ export function StatusPopover() {
const plugins = createMemo(() => sync.data.config.plugin ?? [])
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
const [usageTick, setUsageTick] = createSignal(0)
const [showOnlyUsed, setShowOnlyUsed] = createSignal(false)
const connected = createMemo(() => providers.connected())
const usage = createMemo(() => {
usageTick()
return getProviderUsage(
sdk.directory,
connected().map((item) => item.id),
params.id,
)
})
const overallHealthy = createMemo(() => {
const serverHealthy = server.healthy() === true
const anyMcpIssue = mcpNames().some((name) => {
Expand All @@ -200,6 +215,48 @@ export function StatusPopover() {
return serverHealthy && !anyMcpIssue
})

const name = (id: string) => {
const item = connected().find((row) => row.id === id)
return item?.name ?? id
}

const isPercentProvider = (id: string) => {
const label = `${id} ${name(id)}`.toLowerCase()
return label.includes("openai") || label.includes("claude") || label.includes("anthropic")
}

const usageRows = createMemo(() => {
const list = usage()
if (!showOnlyUsed()) return list
return list.filter((item) => item.weekly.used > 0 || item.session.used > 0)
})

const usagePercent = (used: number, limit: number) => {
if (limit <= 0) return null
return Math.min(100, Math.round((used / limit) * 100))
}

const usagePercentLabel = (used: number, limit: number) => {
const percent = usagePercent(used, limit)
if (percent === null) return "-"
return `${percent}%`
}

const saveLimit = (id: string, key: "weekly" | "session", value: string) => {
const raw = Number.parseInt(value, 10)
const next = Number.isFinite(raw) ? raw : 0
const limit = isPercentProvider(id) ? Math.max(0, Math.min(100, next)) : next
if (key === "weekly") setProviderLimit(sdk.directory, id, { weekly: limit })
if (key === "session") setProviderLimit(sdk.directory, id, { session: limit })
setUsageTick((value) => value + 1)
}

createEffect(() => {
if (!shown()) return
setUsageTick((value) => value + 1)
const id = setInterval(() => setUsageTick((value) => value + 1), 1000)
onCleanup(() => clearInterval(id))
})
return (
<Popover
open={shown()}
Expand Down Expand Up @@ -257,6 +314,10 @@ export function StatusPopover() {
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")}
</Tabs.Trigger>
<Tabs.Trigger value="usage" data-slot="tab" class="text-12-regular">
{connected().length > 0 ? `${connected().length} ` : ""}
Usage
</Tabs.Trigger>
</Tabs.List>

<Tabs.Content value="servers">
Expand Down Expand Up @@ -416,6 +477,91 @@ export function StatusPopover() {
</div>
</div>
</Tabs.Content>

<Tabs.Content value="usage">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14 gap-3">
<div class="flex items-center gap-2 w-full px-2 py-1 border-b border-border-weak-base">
<span class="text-12-regular text-text-weak flex-1">Only providers in use</span>
<Switch checked={showOnlyUsed()} onChange={setShowOnlyUsed} />
</div>
<Show
when={connected().length > 0}
fallback={<div class="text-14-regular text-text-base text-center my-auto">No connected providers</div>}
>
<Show
when={usageRows().length > 0}
fallback={<div class="text-14-regular text-text-base text-center my-auto">No providers with usage yet</div>}
>
<For each={usageRows()}>
{(item) => (
<div class="flex flex-col gap-2 w-full px-2 py-2 border-b border-border-weak-base last:border-b-0">
<div class="flex items-center justify-between">
<span class="text-14-regular text-text-base truncate">{name(item.id)}</span>
<span class="text-12-regular text-text-weak">{item.id}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-12-regular text-text-weak w-14">Weekly</span>
<input
type="number"
min="0"
max={isPercentProvider(item.id) ? "100" : "100000"}
value={item.weekly.limit}
onInput={(event) => saveLimit(item.id, "weekly", event.currentTarget.value)}
class="w-20 bg-transparent text-12-regular text-text-base outline-none border border-border-weak-base rounded-md px-2 py-1"
/>
<span class="text-12-regular text-text-weak">
<Show
when={isPercentProvider(item.id)}
fallback={`${item.weekly.used}/${item.weekly.limit > 0 ? item.weekly.limit : "-"}`}
>
{usagePercentLabel(item.weekly.used, item.weekly.limit)}
</Show>
</span>
</div>
<Show when={isPercentProvider(item.id)}>
<div class="h-1.5 w-full bg-surface-raised-base rounded-full overflow-hidden">
<div
class="h-full transition-all bg-icon-success-base"
style={{ width: `${usagePercent(item.weekly.used, item.weekly.limit) ?? 0}%` }}
/>
</div>
</Show>
<div class="flex items-center gap-2">
<span class="text-12-regular text-text-weak w-14">Session</span>
<input
type="number"
min="0"
max={isPercentProvider(item.id) ? "100" : "100000"}
value={item.session.limit}
onInput={(event) => saveLimit(item.id, "session", event.currentTarget.value)}
class="w-20 bg-transparent text-12-regular text-text-base outline-none border border-border-weak-base rounded-md px-2 py-1"
/>
<span class="text-12-regular text-text-weak">
<Show
when={isPercentProvider(item.id)}
fallback={`${item.session.used}/${item.session.limit > 0 ? item.session.limit : "-"}`}
>
{usagePercentLabel(item.session.used, item.session.limit)}
</Show>
</span>
</div>
<Show when={isPercentProvider(item.id)}>
<div class="h-1.5 w-full bg-surface-raised-base rounded-full overflow-hidden">
<div
class="h-full transition-all bg-icon-success-base"
style={{ width: `${usagePercent(item.session.used, item.session.limit) ?? 0}%` }}
/>
</div>
</Show>
</div>
)}
</For>
</Show>
</Show>
</div>
</div>
</Tabs.Content>
</Tabs>
</div>
</Popover>
Expand Down
102 changes: 102 additions & 0 deletions packages/app/src/utils/provider-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
type Limits = {
weekly: number
session: number
}

type Usage = {
week: string
limits: Record<string, Limits>
weekly: Record<string, number>
session: Record<string, Record<string, number>>
}

export type ProviderUsage = {
id: string
weekly: { used: number; limit: number; remaining: number | null }
session: { used: number; limit: number; remaining: number | null }
}

const defaults: Usage = {
week: "",
limits: {},
weekly: {},
session: {},
}

const week = (date = new Date()) => {
const value = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
const day = value.getUTCDay() || 7
value.setUTCDate(value.getUTCDate() + 4 - day)
const year = value.getUTCFullYear()
const start = new Date(Date.UTC(year, 0, 1))
const number = Math.ceil(((value.getTime() - start.getTime()) / 86_400_000 + 1) / 7)
return `${year}-W${`${number}`.padStart(2, "0")}`
}

const key = (dir: string) => `opencode:provider-usage:${dir}`

const read = (dir: string): Usage => {
if (typeof window === "undefined") return { ...defaults, week: week() }
const raw = window.localStorage.getItem(key(dir))
if (!raw) return { ...defaults, week: week() }
try {
const out = JSON.parse(raw) as Usage
if (out.week === week()) return out
return { ...out, week: week(), weekly: {} }
} catch {
return { ...defaults, week: week() }
}
}

const write = (dir: string, value: Usage) => {
if (typeof window === "undefined") return
window.localStorage.setItem(key(dir), JSON.stringify(value))
}

const clean = (value: number) => {
if (!Number.isFinite(value)) return 0
return Math.max(0, Math.floor(value))
}

export const setProviderLimit = (dir: string, id: string, patch: Partial<Limits>) => {
const data = read(dir)
const limits = data.limits[id] ?? { weekly: 0, session: 0 }
data.limits[id] = {
weekly: patch.weekly === undefined ? limits.weekly : clean(patch.weekly),
session: patch.session === undefined ? limits.session : clean(patch.session),
}
write(dir, data)
}

export const trackProviderUsage = (dir: string, id: string, sessionID?: string) => {
const data = read(dir)
data.weekly[id] = clean(data.weekly[id] ?? 0) + 1
if (sessionID) {
const row = data.session[sessionID] ?? {}
row[id] = clean(row[id] ?? 0) + 1
data.session[sessionID] = row
}
write(dir, data)
}

export const getProviderUsage = (dir: string, ids: string[], sessionID?: string): ProviderUsage[] => {
const data = read(dir)
return ids.map((id) => {
const limits = data.limits[id] ?? { weekly: 0, session: 0 }
const weeklyUsed = clean(data.weekly[id] ?? 0)
const sessionUsed = sessionID ? clean(data.session[sessionID]?.[id] ?? 0) : 0
return {
id,
weekly: {
used: weeklyUsed,
limit: limits.weekly,
remaining: limits.weekly > 0 ? Math.max(0, limits.weekly - weeklyUsed) : null,
},
session: {
used: sessionUsed,
limit: limits.session,
remaining: limits.session > 0 ? Math.max(0, limits.session - sessionUsed) : null,
},
}
})
}
Loading