diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index ba299fe3650..90d953a06be 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -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" @@ -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) @@ -429,6 +432,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { input.onSubmit?.() if (mode === "shell") { + trackProviderUsage(sessionDirectory, model.providerID, session.id) clearInput() client.session .shell({ @@ -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({ diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 063205f0c30..61d39106045 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -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" @@ -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" @@ -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(() => { @@ -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) => { @@ -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 ( 0 ? `${pluginCount()} ` : ""} {language.t("status.popover.tab.plugins")} + + {connected().length > 0 ? `${connected().length} ` : ""} + Usage + @@ -416,6 +477,91 @@ export function StatusPopover() { + + +
+
+
+ Only providers in use + +
+ 0} + fallback={
No connected providers
} + > + 0} + fallback={
No providers with usage yet
} + > + + {(item) => ( +
+
+ {name(item.id)} + {item.id} +
+
+ Weekly + 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" + /> + + 0 ? item.weekly.limit : "-"}`} + > + {usagePercentLabel(item.weekly.used, item.weekly.limit)} + + +
+ +
+
+
+ +
+ Session + 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" + /> + + 0 ? item.session.limit : "-"}`} + > + {usagePercentLabel(item.session.used, item.session.limit)} + + +
+ +
+
+
+ +
+ )} + +
+ +
+
+
diff --git a/packages/app/src/utils/provider-usage.ts b/packages/app/src/utils/provider-usage.ts new file mode 100644 index 00000000000..bc950efc865 --- /dev/null +++ b/packages/app/src/utils/provider-usage.ts @@ -0,0 +1,102 @@ +type Limits = { + weekly: number + session: number +} + +type Usage = { + week: string + limits: Record + weekly: Record + session: Record> +} + +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) => { + 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, + }, + } + }) +}