|
1 | | -import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" |
| 1 | +import { createMemo, createEffect, on, onCleanup, For, Show, createSignal, createResource } from "solid-js" |
2 | 2 | import type { JSX } from "solid-js" |
3 | 3 | import { useParams } from "@solidjs/router" |
4 | 4 | import { DateTime } from "luxon" |
5 | 5 | import { useSync } from "@/context/sync" |
6 | 6 | import { useLayout } from "@/context/layout" |
| 7 | +import { useGlobalSDK } from "@/context/global-sdk" |
| 8 | +import { usePlatform } from "@/context/platform" |
7 | 9 | import { checksum } from "@opencode-ai/util/encode" |
8 | 10 | import { findLast } from "@opencode-ai/util/array" |
9 | 11 | import { Icon } from "@opencode-ai/ui/icon" |
10 | 12 | import { Accordion } from "@opencode-ai/ui/accordion" |
11 | 13 | import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" |
12 | 14 | import { Code } from "@opencode-ai/ui/code" |
13 | 15 | import { Markdown } from "@opencode-ai/ui/markdown" |
| 16 | +import { Spinner } from "@opencode-ai/ui/spinner" |
14 | 17 | import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" |
15 | 18 | import { useLanguage } from "@/context/language" |
16 | 19 |
|
| 20 | +interface AnthropicUsage { |
| 21 | + fiveHour?: { utilization: number; resetsAt?: string } |
| 22 | + sevenDay?: { utilization: number; resetsAt?: string } |
| 23 | + sevenDaySonnet?: { utilization: number; resetsAt?: string } |
| 24 | +} |
| 25 | + |
| 26 | +interface AccountUsage { |
| 27 | + id: string |
| 28 | + label?: string |
| 29 | + isActive?: boolean |
| 30 | + health: { successCount: number; failureCount: number; cooldownUntil?: number } |
| 31 | +} |
| 32 | + |
| 33 | +interface ProviderUsageData { |
| 34 | + accounts: AccountUsage[] |
| 35 | + anthropicUsage?: AnthropicUsage |
| 36 | +} |
| 37 | + |
17 | 38 | interface SessionContextTabProps { |
18 | 39 | messages: () => Message[] |
19 | 40 | visibleUserMessages: () => UserMessage[] |
20 | 41 | view: () => ReturnType<ReturnType<typeof useLayout>["view"]> |
21 | 42 | info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]> |
22 | 43 | } |
23 | 44 |
|
| 45 | +function formatResetTime(resetAt?: string): string { |
| 46 | + if (!resetAt) return "" |
| 47 | + const reset = new Date(resetAt) |
| 48 | + const now = new Date() |
| 49 | + const diffMs = reset.getTime() - now.getTime() |
| 50 | + if (diffMs <= 0) return "now" |
| 51 | + const totalMinutes = Math.floor(diffMs / (1000 * 60)) |
| 52 | + const hours = Math.floor(totalMinutes / 60) |
| 53 | + const minutes = totalMinutes % 60 |
| 54 | + if (hours > 0) return `${hours}h ${minutes}m` |
| 55 | + return `${minutes}m` |
| 56 | +} |
| 57 | + |
| 58 | +function getUsageColor(percent: number): string { |
| 59 | + if (percent <= 50) return "var(--syntax-success)" |
| 60 | + if (percent <= 80) return "var(--syntax-warning)" |
| 61 | + return "var(--syntax-danger)" |
| 62 | +} |
| 63 | + |
| 64 | +function AnthropicUsageSection() { |
| 65 | + const globalSDK = useGlobalSDK() |
| 66 | + const platform = usePlatform() |
| 67 | + const [switching, setSwitching] = createSignal<string | null>(null) |
| 68 | + |
| 69 | + const [usage, { refetch, mutate }] = createResource(async () => { |
| 70 | + const result = await globalSDK.client.auth.usage({}) |
| 71 | + const data = result.data as Record<string, ProviderUsageData> |
| 72 | + return data["anthropic"] |
| 73 | + }) |
| 74 | + |
| 75 | + const switchAccount = async (recordID: string) => { |
| 76 | + setSwitching(recordID) |
| 77 | + try { |
| 78 | + const doFetch = platform.fetch ?? fetch |
| 79 | + const response = await doFetch(`${globalSDK.url}/auth/active`, { |
| 80 | + method: "POST", |
| 81 | + headers: { "Content-Type": "application/json" }, |
| 82 | + body: JSON.stringify({ providerID: "anthropic", recordID }), |
| 83 | + }) |
| 84 | + if (response.ok) { |
| 85 | + const result = await response.json() |
| 86 | + const current = usage() |
| 87 | + if (current && result.success) { |
| 88 | + // Update accounts list to reflect new active status |
| 89 | + // and use anthropicUsage from response (fetched for the specific recordID) |
| 90 | + mutate({ |
| 91 | + ...current, |
| 92 | + accounts: current.accounts.map((acc) => ({ |
| 93 | + ...acc, |
| 94 | + isActive: acc.id === recordID, |
| 95 | + })), |
| 96 | + anthropicUsage: result.anthropicUsage ?? current.anthropicUsage, |
| 97 | + }) |
| 98 | + } |
| 99 | + } |
| 100 | + } catch (e) { |
| 101 | + console.error("Failed to switch account:", e) |
| 102 | + } finally { |
| 103 | + setSwitching(null) |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + const rateLimits = createMemo(() => { |
| 108 | + const data = usage() |
| 109 | + if (!data?.anthropicUsage) return [] |
| 110 | + |
| 111 | + const limits: { key: string; label: string; utilization: number; resetsAt?: string; color: string }[] = [] |
| 112 | + |
| 113 | + if (data.anthropicUsage.fiveHour) { |
| 114 | + limits.push({ |
| 115 | + key: "5h", |
| 116 | + label: "5-Hour", |
| 117 | + utilization: data.anthropicUsage.fiveHour.utilization, |
| 118 | + resetsAt: data.anthropicUsage.fiveHour.resetsAt, |
| 119 | + color: getUsageColor(data.anthropicUsage.fiveHour.utilization), |
| 120 | + }) |
| 121 | + } |
| 122 | + if (data.anthropicUsage.sevenDay) { |
| 123 | + limits.push({ |
| 124 | + key: "7d", |
| 125 | + label: "Weekly (All)", |
| 126 | + utilization: data.anthropicUsage.sevenDay.utilization, |
| 127 | + resetsAt: data.anthropicUsage.sevenDay.resetsAt, |
| 128 | + color: getUsageColor(data.anthropicUsage.sevenDay.utilization), |
| 129 | + }) |
| 130 | + } |
| 131 | + if (data.anthropicUsage.sevenDaySonnet) { |
| 132 | + limits.push({ |
| 133 | + key: "7d-sonnet", |
| 134 | + label: "Weekly (Sonnet)", |
| 135 | + utilization: data.anthropicUsage.sevenDaySonnet.utilization, |
| 136 | + resetsAt: data.anthropicUsage.sevenDaySonnet.resetsAt, |
| 137 | + color: getUsageColor(data.anthropicUsage.sevenDaySonnet.utilization), |
| 138 | + }) |
| 139 | + } |
| 140 | + |
| 141 | + return limits |
| 142 | + }) |
| 143 | + |
| 144 | + return ( |
| 145 | + <div class="flex flex-col gap-2"> |
| 146 | + <div class="text-12-regular text-text-weak">Anthropic Rate Limits</div> |
| 147 | + |
| 148 | + <Show when={usage.loading}> |
| 149 | + <div class="flex items-center justify-center py-4"> |
| 150 | + <Spinner class="size-4" /> |
| 151 | + </div> |
| 152 | + </Show> |
| 153 | + |
| 154 | + <Show when={!usage.loading && usage()}> |
| 155 | + {(data) => ( |
| 156 | + <> |
| 157 | + <Show when={rateLimits().length > 0}> |
| 158 | + <For each={rateLimits()}> |
| 159 | + {(limit) => ( |
| 160 | + <div class="flex flex-col gap-1"> |
| 161 | + <div class="h-2 w-full rounded-full bg-surface-base overflow-hidden"> |
| 162 | + <div |
| 163 | + class="h-full transition-all" |
| 164 | + style={{ |
| 165 | + width: `${limit.utilization}%`, |
| 166 | + "background-color": limit.color, |
| 167 | + }} |
| 168 | + /> |
| 169 | + </div> |
| 170 | + <div class="flex items-center gap-1 text-11-regular text-text-weak"> |
| 171 | + <div class="size-2 rounded-sm" style={{ "background-color": limit.color }} /> |
| 172 | + <div>{limit.label}</div> |
| 173 | + <div class="text-text-weaker">{limit.utilization}%</div> |
| 174 | + <Show when={limit.resetsAt}> |
| 175 | + <div class="text-text-weaker ml-auto">resets {formatResetTime(limit.resetsAt)}</div> |
| 176 | + </Show> |
| 177 | + </div> |
| 178 | + </div> |
| 179 | + )} |
| 180 | + </For> |
| 181 | + </Show> |
| 182 | + |
| 183 | + <Show when={data().accounts.length > 1}> |
| 184 | + <div class="flex flex-col gap-2 mt-2"> |
| 185 | + <div class="text-11-regular text-text-weak">Accounts ({data().accounts.length}) - click to switch</div> |
| 186 | + <div class="flex flex-wrap gap-1"> |
| 187 | + <For each={data().accounts}> |
| 188 | + {(account, index) => { |
| 189 | + const isSwitching = () => switching() === account.id |
| 190 | + const canSwitch = () => !account.isActive && !isSwitching() |
| 191 | + |
| 192 | + return ( |
| 193 | + <button |
| 194 | + type="button" |
| 195 | + disabled={!canSwitch()} |
| 196 | + onClick={() => canSwitch() && switchAccount(account.id)} |
| 197 | + class="px-2 py-1 rounded text-11-medium transition-all" |
| 198 | + classList={{ |
| 199 | + "bg-fill-success-ghost border border-fill-success-base text-fill-success-base": |
| 200 | + account.isActive, |
| 201 | + "bg-surface-base border border-border-base text-text-muted hover:border-border-strong hover:text-text-base cursor-pointer": |
| 202 | + canSwitch(), |
| 203 | + "bg-surface-base border border-border-base text-text-weaker": |
| 204 | + !canSwitch() && !account.isActive, |
| 205 | + }} |
| 206 | + > |
| 207 | + <Show when={isSwitching()}> |
| 208 | + <Spinner class="size-3 mr-1 inline" /> |
| 209 | + </Show> |
| 210 | + {account.label && account.label !== "default" ? account.label : `Account ${index() + 1}`} |
| 211 | + <Show when={account.isActive}> |
| 212 | + <span class="ml-1 text-10-regular">(active)</span> |
| 213 | + </Show> |
| 214 | + </button> |
| 215 | + ) |
| 216 | + }} |
| 217 | + </For> |
| 218 | + </div> |
| 219 | + </div> |
| 220 | + </Show> |
| 221 | + |
| 222 | + <button |
| 223 | + type="button" |
| 224 | + class="text-11-regular text-text-muted hover:text-text-base transition-colors self-start mt-1" |
| 225 | + onClick={() => refetch()} |
| 226 | + > |
| 227 | + Refresh |
| 228 | + </button> |
| 229 | + </> |
| 230 | + )} |
| 231 | + </Show> |
| 232 | + |
| 233 | + <Show when={!usage.loading && !usage()}> |
| 234 | + <div class="text-11-regular text-text-muted p-2 rounded bg-surface-base"> |
| 235 | + No Anthropic OAuth account connected. |
| 236 | + </div> |
| 237 | + </Show> |
| 238 | + </div> |
| 239 | + ) |
| 240 | +} |
| 241 | + |
24 | 242 | export function SessionContextTab(props: SessionContextTabProps) { |
25 | 243 | const params = useParams() |
26 | 244 | const sync = useSync() |
@@ -410,6 +628,11 @@ export function SessionContextTab(props: SessionContextTabProps) { |
410 | 628 | </div> |
411 | 629 | </Show> |
412 | 630 |
|
| 631 | + {/* Anthropic Rate Limits - only show when provider is Anthropic */} |
| 632 | + <Show when={ctx()?.provider?.id === "anthropic"}> |
| 633 | + <AnthropicUsageSection /> |
| 634 | + </Show> |
| 635 | + |
413 | 636 | <Show when={systemPrompt()}> |
414 | 637 | {(prompt) => ( |
415 | 638 | <div class="flex flex-col gap-2"> |
|
0 commit comments