Skip to content

Commit a02099f

Browse files
author
Claude Agent
committed
feat(auth): multi-account OAuth support with auto-relogin
Add support for multiple OAuth accounts per provider with automatic credential rotation and browser-based session management. - Multi-account store: track multiple OAuth records per provider - Credential rotation: automatically failover to next account on rate limits or auth failures (rotating-fetch) - Browser sessions: Puppeteer-based auto-relogin for expired tokens - Credential manager: event-driven failover notifications - AsyncLocalStorage auth context for per-request credential tracking - Provider settings UI: manage accounts, set active, view usage - Session context tab: show which account is active per session - Config: oauth rotation settings (cooldowns, retries, max attempts) - CLI: browser session management commands - Server routes: account CRUD, usage, browser session endpoints - SDK: regenerated with all new multi-account endpoints
1 parent 0d22068 commit a02099f

15 files changed

Lines changed: 4869 additions & 335 deletions

File tree

packages/app/src/components/dialog-settings.tsx

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,6 @@ export const DialogSettings: Component = () => {
6767
<Tabs.Content value="models" class="no-scrollbar">
6868
<SettingsModels />
6969
</Tabs.Content>
70-
{/* <Tabs.Content value="agents" class="no-scrollbar"> */}
71-
{/* <SettingsAgents /> */}
72-
{/* </Tabs.Content> */}
73-
{/* <Tabs.Content value="commands" class="no-scrollbar"> */}
74-
{/* <SettingsCommands /> */}
75-
{/* </Tabs.Content> */}
76-
{/* <Tabs.Content value="mcp" class="no-scrollbar"> */}
77-
{/* <SettingsMcp /> */}
78-
{/* </Tabs.Content> */}
7970
</Tabs>
8071
</Dialog>
8172
)

packages/app/src/components/session/session-context-tab.tsx

Lines changed: 224 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,244 @@
1-
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
1+
import { createMemo, createEffect, on, onCleanup, For, Show, createSignal, createResource } from "solid-js"
22
import type { JSX } from "solid-js"
33
import { useParams } from "@solidjs/router"
44
import { DateTime } from "luxon"
55
import { useSync } from "@/context/sync"
66
import { useLayout } from "@/context/layout"
7+
import { useGlobalSDK } from "@/context/global-sdk"
8+
import { usePlatform } from "@/context/platform"
79
import { checksum } from "@opencode-ai/util/encode"
810
import { findLast } from "@opencode-ai/util/array"
911
import { Icon } from "@opencode-ai/ui/icon"
1012
import { Accordion } from "@opencode-ai/ui/accordion"
1113
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
1214
import { Code } from "@opencode-ai/ui/code"
1315
import { Markdown } from "@opencode-ai/ui/markdown"
16+
import { Spinner } from "@opencode-ai/ui/spinner"
1417
import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
1518
import { useLanguage } from "@/context/language"
1619

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+
1738
interface SessionContextTabProps {
1839
messages: () => Message[]
1940
visibleUserMessages: () => UserMessage[]
2041
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
2142
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
2243
}
2344

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+
24242
export function SessionContextTab(props: SessionContextTabProps) {
25243
const params = useParams()
26244
const sync = useSync()
@@ -410,6 +628,11 @@ export function SessionContextTab(props: SessionContextTabProps) {
410628
</div>
411629
</Show>
412630

631+
{/* Anthropic Rate Limits - only show when provider is Anthropic */}
632+
<Show when={ctx()?.provider?.id === "anthropic"}>
633+
<AnthropicUsageSection />
634+
</Show>
635+
413636
<Show when={systemPrompt()}>
414637
{(prompt) => (
415638
<div class="flex flex-col gap-2">

0 commit comments

Comments
 (0)