feat: credit-based tracking for BrowserOS provider (#489)

* feat: add credit-based tracking for BrowserOS provider

Send X-BrowserOS-ID header on all LLM requests through the BrowserOS
gateway for per-installation credit tracking. Handle 429 CREDITS_EXHAUSTED
as non-retryable. Add GET/PUT /credits endpoints to check and manage
credit balance.

* docs: add credits tracking UI design

Design for showing credit balance in side panel chat header (color-coded
badge) and a dedicated Usage & Billing settings page. Credits refresh
after each completed message turn or on exhaustion error.

* docs: add credits tracking UI implementation plan

8-task plan covering useCredits hook, CreditBadge component, ChatHeader
integration, message completion refresh, ChatError CREDITS_EXHAUSTED
handling, Usage & Billing settings page, and route/sidebar registration.

* feat: add useCredits React Query hook

* feat: add CreditBadge component with color thresholds

* feat: show credit badge in chat header for BrowserOS provider

* feat: refresh credits after chat message completion and on error

* feat: handle CREDITS_EXHAUSTED error in chat

* feat: add Usage & Billing settings page

* feat: register usage page route and sidebar entry

* fix: lint and formatting fixes for credit tracking UI

* fix: separate credits exhausted from Kimi rate limit in ChatError, redesign Usage page

* chore: remove PUT /credits endpoint and setCredits function

* fix: extract shared credit colors, add error state to UsagePage, use dailyLimit from gateway

* fix: make dailyLimit required in CreditsInfo (gateway always returns it)

* feat: gate credits UI behind CREDITS_SUPPORT feature flag (server >= 0.0.78)
This commit is contained in:
shivammittal274
2026-03-20 22:49:00 +05:30
committed by GitHub
parent e3601bfdc1
commit 8548bcf50a
24 changed files with 903 additions and 16 deletions

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"terminal.integrated.tabs.title": "${sequence} ${process}",
"terminal.integrated.tabs.description": "${cwd}"
}

View File

@@ -0,0 +1,26 @@
import { Coins } from 'lucide-react'
import type { FC } from 'react'
import { getCreditTextColor } from '@/lib/credits/credit-colors'
import { cn } from '@/lib/utils'
interface CreditBadgeProps {
credits: number
onClick?: () => void
}
export const CreditBadge: FC<CreditBadgeProps> = ({ credits, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-xs transition-colors hover:bg-muted/50',
getCreditTextColor(credits),
)}
title={`${credits} credits remaining`}
>
<Coins className="h-3.5 w-3.5" />
<span>{credits}</span>
</button>
)
}

View File

@@ -3,6 +3,7 @@ import {
BookOpen,
Bot,
Compass,
CreditCard,
GitBranch,
MessageSquare,
Palette,
@@ -79,6 +80,12 @@ const primarySettingsSections: NavSection[] = [
feature: Feature.CUSTOMIZATION_SUPPORT,
},
{ name: 'BrowserOS as MCP', to: '/settings/mcp', icon: Server },
{
name: 'Usage & Billing',
to: '/settings/usage',
icon: CreditCard,
feature: Feature.CREDITS_SUPPORT,
},
{
name: 'Workflows',
to: '/workflows',

View File

@@ -28,6 +28,7 @@ import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
import { SearchProviderPage } from './search-provider/SearchProviderPage'
import { SkillsPage } from './skills/SkillsPage'
import { SoulPage } from './soul/SoulPage'
import { UsagePage } from './usage/UsagePage'
import { WorkflowsPageWrapper } from './workflows/WorkflowsPageWrapper'
function getSurveyParams(): { maxTurns?: number; experimentId?: string } {
@@ -103,6 +104,7 @@ export const App: FC = () => {
<Route path="customization" element={<CustomizationPage />} />
<Route path="search" element={<SearchProviderPage />} />
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
<Route path="usage" element={<UsagePage />} />
</Route>
</Route>

View File

@@ -0,0 +1,125 @@
import { AlertCircle, Clock, Coins, CreditCard, Zap } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
import {
getCreditBarColor,
getCreditTextColor,
} from '@/lib/credits/credit-colors'
import { useCredits } from '@/lib/credits/useCredits'
import { BrowserOSIcon } from '@/lib/llm-providers/providerIcons'
import { cn } from '@/lib/utils'
export const UsagePage: FC = () => {
const { data, isLoading, error } = useCredits()
if (isLoading) {
return (
<div className="flex items-center justify-center p-12 text-muted-foreground text-sm">
Loading usage data...
</div>
)
}
if (error) {
return (
<div className="space-y-6 p-6">
<div className="flex items-center gap-4 rounded-xl border p-5">
<BrowserOSIcon size={40} />
<div>
<h2 className="font-semibold text-lg">Usage & Billing</h2>
<p className="text-muted-foreground text-sm">
Monitor your BrowserOS AI credit usage
</p>
</div>
</div>
<div className="flex flex-col items-center gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-8">
<AlertCircle className="h-6 w-6 text-muted-foreground" />
<p className="text-muted-foreground text-sm">
Unable to load credit information
</p>
</div>
</div>
)
}
const credits = data?.credits ?? 0
const total = data?.dailyLimit ?? 100
const percentage = Math.min((credits / total) * 100, 100)
return (
<div className="space-y-6 p-6">
<div className="flex items-center gap-4 rounded-xl border p-5">
<BrowserOSIcon size={40} />
<div>
<h2 className="font-semibold text-lg">Usage & Billing</h2>
<p className="text-muted-foreground text-sm">
Monitor your BrowserOS AI credit usage
</p>
</div>
</div>
<div className="rounded-xl border p-5">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Coins className="h-5 w-5 text-muted-foreground" />
<span className="font-semibold text-sm">Daily Credits</span>
</div>
<span
className={cn('font-bold text-2xl', getCreditTextColor(credits))}
>
{credits}
<span className="ml-1 font-normal text-muted-foreground text-sm">
/ {total}
</span>
</span>
</div>
<div className="mb-5 h-2.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
getCreditBarColor(credits),
)}
style={{ width: `${percentage}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2.5">
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium text-xs">Resets daily</p>
<p className="text-muted-foreground text-xs">Midnight UTC</p>
</div>
</div>
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2.5">
<Zap className="h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium text-xs">Credits used today</p>
<p className="text-muted-foreground text-xs">
{total - credits} of {total}
</p>
</div>
</div>
</div>
</div>
<div className="rounded-xl border p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-semibold text-sm">Need more credits?</p>
<p className="text-muted-foreground text-xs">
Additional credit packages coming soon
</p>
</div>
</div>
<Button variant="outline" size="sm" disabled className="opacity-50">
Add Credits
</Button>
</div>
</div>
</div>
)
}

View File

@@ -30,6 +30,7 @@ function parseErrorMessage(message: string): {
text: string
url?: string
isRateLimit?: boolean
isCreditsExhausted?: boolean
isConnectionError?: boolean
} {
// Detect MCP server connection failures
@@ -44,6 +45,19 @@ function parseErrorMessage(message: string): {
}
}
// Detect credit exhaustion from gateway
if (
message.includes('CREDITS_EXHAUSTED') ||
message.includes('Daily credits exhausted')
) {
return {
text: 'Daily credits exhausted. Credits reset at midnight UTC.',
url: '/app.html#/settings/usage',
isRateLimit: true,
isCreditsExhausted: true,
}
}
// Detect BrowserOS rate limit (unique pattern, no provider uses this)
if (message.includes('BrowserOS LLM daily limit reached')) {
return {
@@ -70,9 +84,8 @@ function parseErrorMessage(message: string): {
}
export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
const { text, url, isRateLimit, isConnectionError } = parseErrorMessage(
error.message,
)
const { text, url, isRateLimit, isCreditsExhausted, isConnectionError } =
parseErrorMessage(error.message)
// --- Commented out for Kimi partnership launch (restore after) ---
// const surveyUrl = useMemo(
@@ -128,7 +141,17 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
</p>
)}
--- End commented out survey code --- */}
{isRateLimit && (
{isCreditsExhausted && url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground text-xs underline hover:text-foreground"
>
View Usage & Billing
</a>
)}
{isRateLimit && !isCreditsExhausted && (
<div className="flex flex-col items-center gap-1">
<p className="text-muted-foreground text-xs">
{/* biome-ignore lint/a11y/useValidAnchor: link with click tracking */}

View File

@@ -3,11 +3,27 @@ import type { FC } from 'react'
import { Link, useLocation, useNavigate } from 'react-router'
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
import type { Provider } from '@/components/chat/chatComponentTypes'
import { CreditBadge } from '@/components/credits/CreditBadge'
import { ThemeToggle } from '@/components/elements/theme-toggle'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import { productRepositoryUrl } from '@/lib/constants/productUrls'
import { useCredits } from '@/lib/credits/useCredits'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import type { ProviderType } from '@/lib/llm-providers/types'
const CreditsBadgeWrapper: FC = () => {
const { supports } = useCapabilities()
const { data } = useCredits()
if (!supports(Feature.CREDITS_SUPPORT) || data === undefined) return null
return (
<CreditBadge
credits={data.credits}
onClick={() => window.open('/app.html#/settings/usage', '_blank')}
/>
)
}
interface ChatHeaderProps {
selectedProvider: Provider
providers: Provider[]
@@ -61,6 +77,7 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
</span>
</button>
</ChatProviderSelector>
{selectedProvider.type === 'browseros' && <CreditsBadgeWrapper />}
</div>
<div className="flex items-center gap-1">

View File

@@ -21,6 +21,7 @@ import {
useConversations,
} from '@/lib/conversations/conversationStorage'
import { formatConversationHistory } from '@/lib/conversations/formatConversationHistory'
import { useInvalidateCredits } from '@/lib/credits/useCredits'
import { declinedAppsStorage } from '@/lib/declined-apps/storage'
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage'
@@ -86,6 +87,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
selectedLlmProvider,
isLoadingProviders,
} = useChatRefs()
const invalidateCredits = useInvalidateCredits()
const { providers: llmProviders, setDefaultProvider } = useLlmProviders()
@@ -481,8 +483,14 @@ export const useChatSession = (options?: ChatSessionOptions) => {
} else {
saveLocalConversation(conversationIdRef.current, messagesToSave)
}
invalidateCredits()
}, [status])
useEffect(() => {
if (chatError) invalidateCredits()
}, [chatError, invalidateCredits])
const isIntegrationsSynced = options?.isIntegrationsSynced ?? true
const isIntegrationsSyncedRef = useRef(isIntegrationsSynced)
const pendingMessageRef = useRef<{

View File

@@ -51,6 +51,8 @@ export enum Feature {
GITHUB_COPILOT_SUPPORT = 'GITHUB_COPILOT_SUPPORT',
// Qwen Code OAuth LLM provider
QWEN_CODE_SUPPORT = 'QWEN_CODE_SUPPORT',
// Credit-based usage tracking
CREDITS_SUPPORT = 'CREDITS_SUPPORT',
}
/**
@@ -81,6 +83,7 @@ const FEATURE_CONFIG: { [K in Feature]: FeatureConfig } = {
[Feature.CHATGPT_PRO_SUPPORT]: { minServerVersion: '0.0.77' },
[Feature.GITHUB_COPILOT_SUPPORT]: { minServerVersion: '0.0.77' },
[Feature.QWEN_CODE_SUPPORT]: { minServerVersion: '0.0.77' },
[Feature.CREDITS_SUPPORT]: { minServerVersion: '0.0.78' },
}
function parseVersion(version: string): number[] {

View File

@@ -0,0 +1,13 @@
const LOW_THRESHOLD = 30
export function getCreditTextColor(credits: number): string {
if (credits <= 0) return 'text-red-500'
if (credits <= LOW_THRESHOLD) return 'text-yellow-500'
return 'text-green-500'
}
export function getCreditBarColor(credits: number): string {
if (credits <= 0) return 'bg-red-500'
if (credits <= LOW_THRESHOLD) return 'bg-yellow-500'
return 'bg-green-500'
}

View File

@@ -0,0 +1,33 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
export interface CreditsInfo {
credits: number
dailyLimit: number
lastResetAt?: string
}
const CREDITS_QUERY_KEY = ['credits']
async function fetchCredits(): Promise<CreditsInfo> {
const baseUrl = await getAgentServerUrl()
const response = await fetch(`${baseUrl}/credits`)
if (!response.ok)
throw new Error(`Failed to fetch credits: ${response.status}`)
return response.json()
}
export function useCredits() {
return useQuery<CreditsInfo>({
queryKey: CREDITS_QUERY_KEY,
queryFn: fetchCredits,
refetchOnWindowFocus: true,
staleTime: 30_000,
retry: 1,
})
}
export function useInvalidateCredits() {
const queryClient = useQueryClient()
return () => queryClient.invalidateQueries({ queryKey: CREDITS_QUERY_KEY })
}

View File

@@ -1,6 +1,6 @@
{
"name": "@browseros/server",
"version": "0.0.77",
"version": "0.0.78",
"description": "BrowserOS server",
"type": "module",
"main": "./src/index.ts",

View File

@@ -8,6 +8,7 @@ import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
import type { LanguageModel } from 'ai'
import { createBrowserOSFetch } from '../lib/browseros-fetch'
import { createCodexFetch } from '../lib/clients/oauth/codex-fetch'
import { createCopilotFetch } from '../lib/clients/oauth/copilot-fetch'
import { logger } from '../lib/logger'
@@ -104,26 +105,38 @@ function createBrowserOSFactory(
config: ResolvedAgentConfig,
): (modelId: string) => unknown {
if (!config.baseUrl) throw new Error('BrowserOS provider requires baseUrl')
const { baseUrl, apiKey, upstreamProvider } = config
const { baseUrl, apiKey, upstreamProvider, browserosId } = config
const browserosFetch = browserosId
? createBrowserOSFetch(browserosId)
: createOpenRouterCompatibleFetch()
if (upstreamProvider === LLM_PROVIDERS.OPENROUTER) {
return createOpenRouter({
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: createOpenRouterCompatibleFetch(),
fetch: browserosFetch,
})
}
if (upstreamProvider === LLM_PROVIDERS.ANTHROPIC) {
return createAnthropic({ baseURL: baseUrl, ...(apiKey && { apiKey }) })
return createAnthropic({
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: browserosFetch,
})
}
if (upstreamProvider === LLM_PROVIDERS.AZURE) {
return createAzure({ baseURL: baseUrl, ...(apiKey && { apiKey }) })
return createAzure({
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: browserosFetch,
})
}
logger.info('creating openai-compatible')
logger.debug('Creating OpenAI-compatible provider for BrowserOS')
return createOpenAICompatible({
name: 'browseros',
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: browserosFetch,
})
}

View File

@@ -46,4 +46,6 @@ export interface ResolvedAgentConfig {
isScheduledTask?: boolean
/** Apps the user previously declined to connect via MCP (chose "do it manually"). */
declinedApps?: string[]
/** BrowserOS installation ID for credit-based tracking. */
browserosId?: string
}

View File

@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { Hono } from 'hono'
import { fetchCredits } from '../../lib/clients/gateway'
import { logger } from '../../lib/logger'
interface CreditsDeps {
browserosId?: string
gatewayBaseUrl?: string
}
export function createCreditsRoutes(deps: CreditsDeps) {
const { browserosId, gatewayBaseUrl } = deps
if (!browserosId || !gatewayBaseUrl) {
return new Hono().all('/*', (c) =>
c.json({ error: 'Credits not configured' }, 503),
)
}
return new Hono().get('/', async (c) => {
try {
const credits = await fetchCredits(gatewayBaseUrl, browserosId)
return c.json(credits)
} catch (error) {
logger.error('Failed to fetch credits', {
error: error instanceof Error ? error.message : String(error),
})
return c.json({ error: 'Failed to fetch credits' }, 502)
}
})
}

View File

@@ -14,11 +14,13 @@ import { Hono } from 'hono'
import { cors } from 'hono/cors'
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import { HttpAgentError } from '../agent/errors'
import { INLINED_ENV } from '../env'
import { KlavisClient } from '../lib/clients/klavis/klavis-client'
import { initializeOAuth } from '../lib/clients/oauth'
import { getDb } from '../lib/db'
import { logger } from '../lib/logger'
import { createChatRoutes } from './routes/chat'
import { createCreditsRoutes } from './routes/credits'
import { createGraphRoutes } from './routes/graph'
import { createHealthRoute } from './routes/health'
import { createKlavisRoutes } from './routes/klavis'
@@ -132,6 +134,15 @@ export async function createHttpServer(config: HttpServerConfig) {
),
)
.route('/klavis', createKlavisRoutes({ browserosId: browserosId || '' }))
.route(
'/credits',
createCreditsRoutes({
browserosId,
gatewayBaseUrl: INLINED_ENV.BROWSEROS_CONFIG_URL
? new URL(INLINED_ENV.BROWSEROS_CONFIG_URL).origin
: undefined,
}),
)
.route(
'/mcp',
createMcpRoutes({

View File

@@ -64,6 +64,7 @@ export class ChatService {
chatMode: request.mode === 'chat',
isScheduledTask: request.isScheduledTask,
declinedApps: request.declinedApps,
browserosId: this.deps.browserosId,
}
let session = sessionStore.get(request.conversationId)

View File

@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Custom fetch for BrowserOS gateway requests.
* Adds X-BrowserOS-ID header for credit tracking,
* handles CREDITS_EXHAUSTED (429), and extracts OpenRouter-style error details.
*/
import { APICallError } from '@ai-sdk/provider'
import { logger } from './logger'
function resolveUrl(url: RequestInfo | URL): string {
return typeof url === 'string' ? url : url.toString()
}
function parseErrorBody(
body: string,
): { message?: string; code?: string; metadata?: { raw?: unknown } } | null {
try {
const parsed = JSON.parse(body)
return parsed.error ?? null
} catch {
return null
}
}
function buildErrorMessage(
statusCode: number,
statusText: string,
error: NonNullable<ReturnType<typeof parseErrorBody>>,
): string {
if (!error.message) return `HTTP ${statusCode}: ${statusText}`
let msg = error.message
if (error.code) msg = `[${error.code}] ${msg}`
if (error.metadata?.raw) msg += ` (${JSON.stringify(error.metadata.raw)})`
return msg
}
export function createBrowserOSFetch(browserosId: string): typeof fetch {
return (async (url: RequestInfo | URL, options?: RequestInit) => {
const headers = new Headers(options?.headers)
headers.set('X-BrowserOS-ID', browserosId)
const response = await globalThis.fetch(url, { ...options, headers })
const creditsRemaining = response.headers.get('X-Credits-Remaining')
if (creditsRemaining !== null) {
logger.debug('Credits remaining', { creditsRemaining })
}
if (!response.ok) {
const statusCode = response.status
const responseBody = await response.text()
const error = parseErrorBody(responseBody)
if (statusCode === 429 && error?.code === 'CREDITS_EXHAUSTED') {
throw new APICallError({
message: error.message ?? 'Daily credits exhausted',
url: resolveUrl(url),
requestBodyValues: {},
statusCode,
responseBody,
isRetryable: false,
})
}
throw new APICallError({
message: error
? buildErrorMessage(statusCode, response.statusText, error)
: `HTTP ${statusCode}: ${response.statusText}`,
url: resolveUrl(url),
requestBodyValues: {},
statusCode,
responseBody,
})
}
return response
}) as typeof fetch
}

View File

@@ -11,9 +11,18 @@ export interface Provider {
apiKey: string
baseUrl?: string
dailyRateLimit?: number
dailyCredits?: number
creditCostPerRequest?: number
resetInterval?: string
providerType?: string // LLMProvider value from ai-gateway: "openrouter" | "azure" | "anthropic"
}
export interface CreditsInfo {
credits: number
dailyLimit: number
lastResetAt?: string
}
export interface BrowserOSConfig {
providers: Provider[]
}
@@ -109,3 +118,20 @@ export function getLLMConfigFromProvider(
providerType: provider.providerType,
}
}
export async function fetchCredits(
gatewayBaseUrl: string,
browserosId: string,
): Promise<CreditsInfo> {
const url = new URL(`/credits/${browserosId}`, gatewayBaseUrl).href
const response = await fetch(url)
if (!response.ok) {
const errorText = await response.text()
throw new Error(
`Failed to fetch credits: ${response.status} ${response.statusText} - ${errorText}`,
)
}
const result = (await response.json()) as CreditsInfo
logger.debug('Credits fetched', { credits: result.credits })
return result
}

View File

@@ -119,5 +119,6 @@ async function resolveBrowserOSConfig(
apiKey: llmConfig.apiKey,
baseUrl: llmConfig.baseUrl,
upstreamProvider: llmConfig.providerType,
browserosId,
}
}

View File

@@ -16,6 +16,7 @@ import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
import type { LanguageModel } from 'ai'
import { createBrowserOSFetch } from '../../browseros-fetch'
import { logger } from '../../logger'
import { createOpenRouterCompatibleFetch } from '../../openrouter-fetch'
import { createCodexFetch } from '../oauth/codex-fetch'
@@ -92,28 +93,38 @@ function createBedrockModel(config: ResolvedLLMConfig): LanguageModel {
function createBrowserOSModel(config: ResolvedLLMConfig): LanguageModel {
if (!config.baseUrl) throw new Error('BrowserOS provider requires baseUrl')
const { baseUrl, apiKey, model, upstreamProvider } = config
const { baseUrl, apiKey, model, upstreamProvider, browserosId } = config
const browserosFetch = browserosId
? createBrowserOSFetch(browserosId)
: createOpenRouterCompatibleFetch()
if (upstreamProvider === LLM_PROVIDERS.OPENROUTER) {
return createOpenRouter({
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: createOpenRouterCompatibleFetch(),
fetch: browserosFetch,
})(model)
}
if (upstreamProvider === LLM_PROVIDERS.ANTHROPIC) {
return createAnthropic({ baseURL: baseUrl, ...(apiKey && { apiKey }) })(
model,
)
return createAnthropic({
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: browserosFetch,
})(model)
}
if (upstreamProvider === LLM_PROVIDERS.AZURE) {
return createAzure({ baseURL: baseUrl, ...(apiKey && { apiKey }) })(model)
return createAzure({
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: browserosFetch,
})(model)
}
logger.debug('Creating OpenAI-compatible provider for BrowserOS')
return createOpenAICompatible({
name: 'browseros',
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: browserosFetch,
})(model)
}

View File

@@ -11,5 +11,6 @@ import type { LLMConfig } from '@browseros/shared/schemas/llm'
export interface ResolvedLLMConfig extends LLMConfig {
model: string
upstreamProvider?: string
browserosId?: string
accountId?: string
}

View File

@@ -0,0 +1,58 @@
# Credits Tracking UI Design
## Overview
Surface credit balance to users across two locations: a compact badge in the side panel chat header, and a dedicated Usage & Billing settings page. Credits refresh after each completed message turn or on error.
## 1. Side Panel — Credit Badge
**Location:** Chat header, next to provider selector. Only visible when provider is `browseros`.
**Display:**
- Coin/credit icon + remaining count (e.g., "87")
- Color-coded by threshold:
- Green: >30 credits
- Yellow/orange: 130 credits
- Red: 0 credits
- Clicking the badge navigates to the Usage & Billing settings page
**Update triggers:**
- Message turn completes successfully (agent finishes all tool calls and responds)
- CREDITS_EXHAUSTED error mid-turn (badge syncs to 0, error shown in chat)
## 2. Settings — Usage & Billing Page
**Sidebar entry:** "Usage & Billing" in the "Other" section (icon: CreditCard or Coins).
**Route:** `/settings/usage`
**Content:**
- Credits card: large display of remaining credits (e.g., "87 / 100") with color-coded progress bar
- Reset info: "Resets daily at midnight UTC" with last reset date
- Credit cost: "1 credit per request"
- Placeholder section: "Need more credits?" with disabled "Add Credits" button (future payment/recharge)
## 3. Data Flow
**Hook:** `useCredits()` — React Query hook fetching `GET /credits` from the agent server.
**Refresh strategy:**
- Refetch after each completed message turn (`onFinish` callback in chat session)
- Refetch on CREDITS_EXHAUSTED error
- Refetch on window focus (React Query default)
- No aggressive polling
**State sharing:** Credits query is global (React Query cache). Both side panel badge and settings page read from the same cache key.
## 4. Error Handling (0 credits)
When credits are exhausted mid-conversation:
- Chat stream shows error via existing `ChatError.tsx` pattern: "Daily credits exhausted. Resets at midnight UTC." with link to Usage & Billing page
- Header badge turns red (0 credits)
- Chat input stays enabled — user can switch to a different provider
## 5. Future Hooks
- "Add Credits" button on Usage & Billing page (currently disabled placeholder)
- Payment integration will live entirely within the Usage & Billing page
- Credit badge could show a "+" icon when balance is low, linking to recharge

View File

@@ -0,0 +1,384 @@
# Credits Tracking UI Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Show credit balance in the side panel chat header and a dedicated Usage & Billing settings page, with live updates after each message turn.
**Architecture:** A `useCredits()` React Query hook fetches `GET /credits` from the agent server. The side panel header shows a color-coded badge (green >30, yellow 1-30, red 0). A new settings page at `/settings/usage` shows full details. Credits refresh after each completed chat turn or on CREDITS_EXHAUSTED error.
**Tech Stack:** React, React Query, Shadcn UI, Lucide icons, Hono (server already done)
---
### Task 1: Create useCredits() hook
**Files:**
- Create: `apps/agent/lib/credits/useCredits.ts`
**Step 1: Write the hook**
```typescript
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
interface CreditsInfo {
credits: number
lastResetAt?: string
}
const CREDITS_QUERY_KEY = ['credits']
async function fetchCredits(): Promise<CreditsInfo> {
const baseUrl = await getAgentServerUrl()
const response = await fetch(`${baseUrl}/credits`)
if (!response.ok) throw new Error(`Failed to fetch credits: ${response.status}`)
return response.json()
}
export function useCredits() {
return useQuery<CreditsInfo>({
queryKey: CREDITS_QUERY_KEY,
queryFn: fetchCredits,
refetchOnWindowFocus: true,
staleTime: 30_000,
retry: 1,
})
}
export function useInvalidateCredits() {
const queryClient = useQueryClient()
return () => queryClient.invalidateQueries({ queryKey: CREDITS_QUERY_KEY })
}
```
**Step 2: Commit**
```bash
git add apps/agent/lib/credits/useCredits.ts
git commit -m "feat: add useCredits React Query hook"
```
---
### Task 2: Create CreditBadge component
**Files:**
- Create: `apps/agent/components/credits/CreditBadge.tsx`
**Step 1: Write the component**
The badge shows a coin icon + credit count, color-coded by threshold. Only renders when credits data is available.
```tsx
import { Coins } from 'lucide-react'
import type { FC } from 'react'
import { cn } from '@/lib/utils'
interface CreditBadgeProps {
credits: number
onClick?: () => void
}
function getCreditColor(credits: number): string {
if (credits <= 0) return 'text-red-500'
if (credits <= 30) return 'text-yellow-500'
return 'text-green-500'
}
export const CreditBadge: FC<CreditBadgeProps> = ({ credits, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-medium transition-colors hover:bg-muted/50',
getCreditColor(credits),
)}
title={`${credits} credits remaining`}
>
<Coins className="h-3.5 w-3.5" />
<span>{credits}</span>
</button>
)
}
```
**Step 2: Commit**
```bash
git add apps/agent/components/credits/CreditBadge.tsx
git commit -m "feat: add CreditBadge component with color thresholds"
```
---
### Task 3: Add CreditBadge to ChatHeader
**Files:**
- Modify: `apps/agent/entrypoints/sidepanel/index/ChatHeader.tsx`
**Step 1: Update ChatHeader**
Add the credit badge after the provider selector, only when provider is `browseros`. The badge links to the Usage & Billing settings page.
Changes to `ChatHeader.tsx`:
1. Import `CreditBadge` and `useCredits`
2. After the `ChatProviderSelector` closing tag (line 61), add the badge conditionally
```tsx
// Add imports at top:
import { CreditBadge } from '@/components/credits/CreditBadge'
import { useCredits } from '@/lib/credits/useCredits'
// After line 61 (closing </ChatProviderSelector>), before closing </div>:
{selectedProvider.type === 'browseros' && <CreditsBadgeWrapper />}
```
Create a small wrapper component inside the file to keep the hook call conditional:
```tsx
const CreditsBadgeWrapper: FC = () => {
const { data } = useCredits()
if (data === undefined) return null
return (
<CreditBadge
credits={data.credits}
onClick={() => window.open('/app.html#/settings/usage', '_blank')}
/>
)
}
```
**Step 2: Commit**
```bash
git add apps/agent/entrypoints/sidepanel/index/ChatHeader.tsx
git commit -m "feat: show credit badge in chat header for BrowserOS provider"
```
---
### Task 4: Add credit refresh on message completion
**Files:**
- Modify: `apps/agent/entrypoints/sidepanel/index/useChatSession.ts`
**Step 1: Update useChatSession**
Import `useInvalidateCredits` and call it when a message turn completes (status transitions from streaming/submitted to ready) and when an error occurs.
```typescript
// Add import:
import { useInvalidateCredits } from '@/lib/credits/useCredits'
// Inside useChatSession(), near other hook calls:
const invalidateCredits = useInvalidateCredits()
```
Find the existing completion detection logic (where `saveLocalConversation` or `saveRemoteConversation` is called after status becomes 'ready'). Add `invalidateCredits()` call there.
Also, in the error handling path (where `chatError` is set), add `invalidateCredits()` to sync badge on CREDITS_EXHAUSTED.
**Step 2: Commit**
```bash
git add apps/agent/entrypoints/sidepanel/index/useChatSession.ts
git commit -m "feat: refresh credits after chat message completion and on error"
```
---
### Task 5: Update ChatError for CREDITS_EXHAUSTED
**Files:**
- Modify: `apps/agent/entrypoints/sidepanel/index/ChatError.tsx`
**Step 1: Add CREDITS_EXHAUSTED detection to parseErrorMessage**
In `parseErrorMessage()` (line 29), add a new detection block after the existing rate limit check (line 48):
```typescript
// After the 'BrowserOS LLM daily limit reached' block, add:
if (message.includes('CREDITS_EXHAUSTED') || message.includes('Daily credits exhausted')) {
return {
text: 'Daily credits exhausted. Credits reset at midnight UTC.',
url: '/app.html#/settings/usage',
isRateLimit: true,
}
}
```
**Step 2: Commit**
```bash
git add apps/agent/entrypoints/sidepanel/index/ChatError.tsx
git commit -m "feat: handle CREDITS_EXHAUSTED error in chat"
```
---
### Task 6: Create Usage & Billing settings page
**Files:**
- Create: `apps/agent/entrypoints/app/usage/UsagePage.tsx`
**Step 1: Write the page component**
Follow the same pattern as `AISettingsPage.tsx` — a standalone page component rendered inside the settings sidebar layout.
```tsx
import { Coins } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { useCredits } from '@/lib/credits/useCredits'
import { cn } from '@/lib/utils'
function getCreditColor(credits: number): string {
if (credits <= 0) return 'text-red-500'
if (credits <= 30) return 'text-yellow-500'
return 'text-green-500'
}
function getProgressColor(credits: number): string {
if (credits <= 0) return 'bg-red-500'
if (credits <= 30) return 'bg-yellow-500'
return 'bg-green-500'
}
export const UsagePage: FC = () => {
const { data, isLoading } = useCredits()
if (isLoading) {
return (
<div className="flex items-center justify-center p-12 text-muted-foreground text-sm">
Loading usage data...
</div>
)
}
const credits = data?.credits ?? 0
const total = 100
const percentage = Math.min((credits / total) * 100, 100)
return (
<div className="space-y-6 p-6">
<div>
<h2 className="font-semibold text-lg">Usage & Billing</h2>
<p className="text-muted-foreground text-sm">
Monitor your BrowserOS AI credit usage.
</p>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Coins className="h-5 w-5" />
Credits
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-baseline gap-2">
<span className={cn('font-bold text-3xl', getCreditColor(credits))}>
{credits}
</span>
<span className="text-muted-foreground text-sm">/ {total} daily</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full rounded-full transition-all', getProgressColor(credits))}
style={{ width: `${percentage}%` }}
/>
</div>
<div className="space-y-1 text-muted-foreground text-sm">
<p>1 credit per request</p>
<p>Resets daily at midnight UTC</p>
{data?.lastResetAt && (
<p>Last reset: {new Date(data.lastResetAt).toLocaleDateString()}</p>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Need more credits?</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-3 text-muted-foreground text-sm">
Additional credit packages will be available soon.
</p>
<Button variant="outline" disabled>
Add Credits (Coming Soon)
</Button>
</CardContent>
</Card>
</div>
)
}
```
**Step 2: Commit**
```bash
git add apps/agent/entrypoints/app/usage/UsagePage.tsx
git commit -m "feat: add Usage & Billing settings page"
```
---
### Task 7: Register route and sidebar entry
**Files:**
- Modify: `apps/agent/entrypoints/app/App.tsx` — add route
- Modify: `apps/agent/components/sidebar/SettingsSidebar.tsx` — add sidebar entry
**Step 1: Add route to App.tsx**
Inside the `<Route path="settings">` block (after line 103, before closing `</Route>`):
```tsx
import { UsagePage } from './usage/UsagePage'
// Add as new route:
<Route path="usage" element={<UsagePage />} />
```
**Step 2: Add sidebar entry to SettingsSidebar.tsx**
Import `CreditCard` from lucide-react (line 1). Add entry to the "Other" section in `primarySettingsSections` array (after line 81):
```typescript
{ name: 'Usage & Billing', to: '/settings/usage', icon: CreditCard },
```
**Step 3: Commit**
```bash
git add apps/agent/entrypoints/app/App.tsx apps/agent/components/sidebar/SettingsSidebar.tsx
git commit -m "feat: register usage page route and sidebar entry"
```
---
### Task 8: Verify end-to-end
**Step 1: Start dev server**
```bash
bun run dev:watch -- --new
```
**Step 2: Visual verification checklist**
- [ ] Open side panel — credit badge shows next to BrowserOS provider name
- [ ] Badge color is green when credits > 30
- [ ] Send a chat message — after response completes, badge count decrements
- [ ] Click badge — opens settings/usage page
- [ ] Settings sidebar shows "Usage & Billing" under "Other"
- [ ] Usage page shows credit count, progress bar, reset info
- [ ] Exhaust credits — badge turns red, chat shows error message
**Step 3: Commit any fixes**