From 8548bcf50aaeae3560954716d1af7cad88c68f0e Mon Sep 17 00:00:00 2001
From: shivammittal274 <56757235+shivammittal274@users.noreply.github.com>
Date: Fri, 20 Mar 2026 22:49:00 +0530
Subject: [PATCH] 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)
---
.vscode/settings.json | 4 +
.../agent/components/credits/CreditBadge.tsx | 26 ++
.../components/sidebar/SettingsSidebar.tsx | 7 +
.../apps/agent/entrypoints/app/App.tsx | 2 +
.../agent/entrypoints/app/usage/UsagePage.tsx | 125 ++++++
.../entrypoints/sidepanel/index/ChatError.tsx | 31 +-
.../sidepanel/index/ChatHeader.tsx | 17 +
.../sidepanel/index/useChatSession.ts | 8 +
.../apps/agent/lib/browseros/capabilities.ts | 3 +
.../apps/agent/lib/credits/credit-colors.ts | 13 +
.../apps/agent/lib/credits/useCredits.ts | 33 ++
.../browseros-agent/apps/server/package.json | 2 +-
.../apps/server/src/agent/provider-factory.ts | 23 +-
.../apps/server/src/agent/types.ts | 2 +
.../apps/server/src/api/routes/credits.ts | 36 ++
.../apps/server/src/api/server.ts | 11 +
.../server/src/api/services/chat-service.ts | 1 +
.../apps/server/src/lib/browseros-fetch.ts | 82 ++++
.../apps/server/src/lib/clients/gateway.ts | 26 ++
.../apps/server/src/lib/clients/llm/config.ts | 1 +
.../server/src/lib/clients/llm/provider.ts | 23 +-
.../apps/server/src/lib/clients/llm/types.ts | 1 +
.../2026-03-19-credits-tracking-ui-design.md | 58 +++
.../plans/2026-03-19-credits-tracking-ui.md | 384 ++++++++++++++++++
24 files changed, 903 insertions(+), 16 deletions(-)
create mode 100644 .vscode/settings.json
create mode 100644 packages/browseros-agent/apps/agent/components/credits/CreditBadge.tsx
create mode 100644 packages/browseros-agent/apps/agent/entrypoints/app/usage/UsagePage.tsx
create mode 100644 packages/browseros-agent/apps/agent/lib/credits/credit-colors.ts
create mode 100644 packages/browseros-agent/apps/agent/lib/credits/useCredits.ts
create mode 100644 packages/browseros-agent/apps/server/src/api/routes/credits.ts
create mode 100644 packages/browseros-agent/apps/server/src/lib/browseros-fetch.ts
create mode 100644 packages/browseros-agent/docs/plans/2026-03-19-credits-tracking-ui-design.md
create mode 100644 packages/browseros-agent/docs/plans/2026-03-19-credits-tracking-ui.md
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 000000000..e88781a2f
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,4 @@
+{
+ "terminal.integrated.tabs.title": "${sequence} ${process}",
+ "terminal.integrated.tabs.description": "${cwd}"
+}
diff --git a/packages/browseros-agent/apps/agent/components/credits/CreditBadge.tsx b/packages/browseros-agent/apps/agent/components/credits/CreditBadge.tsx
new file mode 100644
index 000000000..182b5bc83
--- /dev/null
+++ b/packages/browseros-agent/apps/agent/components/credits/CreditBadge.tsx
@@ -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 = ({ credits, onClick }) => {
+ return (
+
+ )
+}
diff --git a/packages/browseros-agent/apps/agent/components/sidebar/SettingsSidebar.tsx b/packages/browseros-agent/apps/agent/components/sidebar/SettingsSidebar.tsx
index 3668d0680..47361fd48 100644
--- a/packages/browseros-agent/apps/agent/components/sidebar/SettingsSidebar.tsx
+++ b/packages/browseros-agent/apps/agent/components/sidebar/SettingsSidebar.tsx
@@ -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',
diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/App.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/App.tsx
index 9a56efd31..c20c84db2 100644
--- a/packages/browseros-agent/apps/agent/entrypoints/app/App.tsx
+++ b/packages/browseros-agent/apps/agent/entrypoints/app/App.tsx
@@ -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 = () => {
} />
} />
} />
+ } />
diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/usage/UsagePage.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/usage/UsagePage.tsx
new file mode 100644
index 000000000..3d8ae7de5
--- /dev/null
+++ b/packages/browseros-agent/apps/agent/entrypoints/app/usage/UsagePage.tsx
@@ -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 (
+
+ Loading usage data...
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
+
+
Usage & Billing
+
+ Monitor your BrowserOS AI credit usage
+
+
+
+
+
+
+ Unable to load credit information
+
+
+
+ )
+ }
+
+ const credits = data?.credits ?? 0
+ const total = data?.dailyLimit ?? 100
+ const percentage = Math.min((credits / total) * 100, 100)
+
+ return (
+
+
+
+
+
Usage & Billing
+
+ Monitor your BrowserOS AI credit usage
+
+
+
+
+
+
+
+
+ Daily Credits
+
+
+ {credits}
+
+ / {total}
+
+
+
+
+
+
+
+
+
+
+
Resets daily
+
Midnight UTC
+
+
+
+
+
+
Credits used today
+
+ {total - credits} of {total}
+
+
+
+
+
+
+
+
+
+
+
+
Need more credits?
+
+ Additional credit packages coming soon
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/ChatError.tsx b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/ChatError.tsx
index ef9621fcd..1eaaff63c 100644
--- a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/ChatError.tsx
+++ b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/ChatError.tsx
@@ -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 = ({ 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 = ({ error, onRetry }) => {
)}
--- End commented out survey code --- */}
- {isRateLimit && (
+ {isCreditsExhausted && url && (
+
+ View Usage & Billing
+
+ )}
+ {isRateLimit && !isCreditsExhausted && (
{/* biome-ignore lint/a11y/useValidAnchor: link with click tracking */}
diff --git a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/ChatHeader.tsx b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/ChatHeader.tsx
index 411aaeb32..27b2c5d87 100644
--- a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/ChatHeader.tsx
+++ b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/ChatHeader.tsx
@@ -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 (
+ window.open('/app.html#/settings/usage', '_blank')}
+ />
+ )
+}
+
interface ChatHeaderProps {
selectedProvider: Provider
providers: Provider[]
@@ -61,6 +77,7 @@ export const ChatHeader: FC = ({
+ {selectedProvider.type === 'browseros' && }
diff --git a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/useChatSession.ts b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/useChatSession.ts
index 1e0e41885..fcdb6fbfe 100644
--- a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/useChatSession.ts
+++ b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/useChatSession.ts
@@ -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<{
diff --git a/packages/browseros-agent/apps/agent/lib/browseros/capabilities.ts b/packages/browseros-agent/apps/agent/lib/browseros/capabilities.ts
index ca4fcf37e..e57fdb68f 100644
--- a/packages/browseros-agent/apps/agent/lib/browseros/capabilities.ts
+++ b/packages/browseros-agent/apps/agent/lib/browseros/capabilities.ts
@@ -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[] {
diff --git a/packages/browseros-agent/apps/agent/lib/credits/credit-colors.ts b/packages/browseros-agent/apps/agent/lib/credits/credit-colors.ts
new file mode 100644
index 000000000..10face4f6
--- /dev/null
+++ b/packages/browseros-agent/apps/agent/lib/credits/credit-colors.ts
@@ -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'
+}
diff --git a/packages/browseros-agent/apps/agent/lib/credits/useCredits.ts b/packages/browseros-agent/apps/agent/lib/credits/useCredits.ts
new file mode 100644
index 000000000..e648881f5
--- /dev/null
+++ b/packages/browseros-agent/apps/agent/lib/credits/useCredits.ts
@@ -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 {
+ 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({
+ 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 })
+}
diff --git a/packages/browseros-agent/apps/server/package.json b/packages/browseros-agent/apps/server/package.json
index 6a341468f..317539fe7 100644
--- a/packages/browseros-agent/apps/server/package.json
+++ b/packages/browseros-agent/apps/server/package.json
@@ -1,6 +1,6 @@
{
"name": "@browseros/server",
- "version": "0.0.77",
+ "version": "0.0.78",
"description": "BrowserOS server",
"type": "module",
"main": "./src/index.ts",
diff --git a/packages/browseros-agent/apps/server/src/agent/provider-factory.ts b/packages/browseros-agent/apps/server/src/agent/provider-factory.ts
index cd67168c6..ac9f5e143 100644
--- a/packages/browseros-agent/apps/server/src/agent/provider-factory.ts
+++ b/packages/browseros-agent/apps/server/src/agent/provider-factory.ts
@@ -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,
})
}
diff --git a/packages/browseros-agent/apps/server/src/agent/types.ts b/packages/browseros-agent/apps/server/src/agent/types.ts
index ab17e7ab9..90a700be8 100644
--- a/packages/browseros-agent/apps/server/src/agent/types.ts
+++ b/packages/browseros-agent/apps/server/src/agent/types.ts
@@ -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
}
diff --git a/packages/browseros-agent/apps/server/src/api/routes/credits.ts b/packages/browseros-agent/apps/server/src/api/routes/credits.ts
new file mode 100644
index 000000000..ccd433b5c
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/api/routes/credits.ts
@@ -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)
+ }
+ })
+}
diff --git a/packages/browseros-agent/apps/server/src/api/server.ts b/packages/browseros-agent/apps/server/src/api/server.ts
index 047c06e30..5eadcb37d 100644
--- a/packages/browseros-agent/apps/server/src/api/server.ts
+++ b/packages/browseros-agent/apps/server/src/api/server.ts
@@ -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({
diff --git a/packages/browseros-agent/apps/server/src/api/services/chat-service.ts b/packages/browseros-agent/apps/server/src/api/services/chat-service.ts
index d9a8664d6..961ca0d97 100644
--- a/packages/browseros-agent/apps/server/src/api/services/chat-service.ts
+++ b/packages/browseros-agent/apps/server/src/api/services/chat-service.ts
@@ -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)
diff --git a/packages/browseros-agent/apps/server/src/lib/browseros-fetch.ts b/packages/browseros-agent/apps/server/src/lib/browseros-fetch.ts
new file mode 100644
index 000000000..61bf1d827
--- /dev/null
+++ b/packages/browseros-agent/apps/server/src/lib/browseros-fetch.ts
@@ -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>,
+): 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
+}
diff --git a/packages/browseros-agent/apps/server/src/lib/clients/gateway.ts b/packages/browseros-agent/apps/server/src/lib/clients/gateway.ts
index b7e0d85d5..17efe9aea 100644
--- a/packages/browseros-agent/apps/server/src/lib/clients/gateway.ts
+++ b/packages/browseros-agent/apps/server/src/lib/clients/gateway.ts
@@ -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 {
+ 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
+}
diff --git a/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts b/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts
index 8cb2058af..62f9bb25b 100644
--- a/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts
+++ b/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts
@@ -119,5 +119,6 @@ async function resolveBrowserOSConfig(
apiKey: llmConfig.apiKey,
baseUrl: llmConfig.baseUrl,
upstreamProvider: llmConfig.providerType,
+ browserosId,
}
}
diff --git a/packages/browseros-agent/apps/server/src/lib/clients/llm/provider.ts b/packages/browseros-agent/apps/server/src/lib/clients/llm/provider.ts
index f9f696975..106752b8d 100644
--- a/packages/browseros-agent/apps/server/src/lib/clients/llm/provider.ts
+++ b/packages/browseros-agent/apps/server/src/lib/clients/llm/provider.ts
@@ -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)
}
diff --git a/packages/browseros-agent/apps/server/src/lib/clients/llm/types.ts b/packages/browseros-agent/apps/server/src/lib/clients/llm/types.ts
index 0374e75fc..235d8e927 100644
--- a/packages/browseros-agent/apps/server/src/lib/clients/llm/types.ts
+++ b/packages/browseros-agent/apps/server/src/lib/clients/llm/types.ts
@@ -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
}
diff --git a/packages/browseros-agent/docs/plans/2026-03-19-credits-tracking-ui-design.md b/packages/browseros-agent/docs/plans/2026-03-19-credits-tracking-ui-design.md
new file mode 100644
index 000000000..5202e1448
--- /dev/null
+++ b/packages/browseros-agent/docs/plans/2026-03-19-credits-tracking-ui-design.md
@@ -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: 1–30 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
diff --git a/packages/browseros-agent/docs/plans/2026-03-19-credits-tracking-ui.md b/packages/browseros-agent/docs/plans/2026-03-19-credits-tracking-ui.md
new file mode 100644
index 000000000..dcedd7acf
--- /dev/null
+++ b/packages/browseros-agent/docs/plans/2026-03-19-credits-tracking-ui.md
@@ -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 {
+ 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({
+ 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 = ({ credits, onClick }) => {
+ return (
+
+ )
+}
+```
+
+**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 ), before closing
:
+{selectedProvider.type === 'browseros' && }
+```
+
+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 (
+ 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 (
+
+ Loading usage data...
+
+ )
+ }
+
+ const credits = data?.credits ?? 0
+ const total = 100
+ const percentage = Math.min((credits / total) * 100, 100)
+
+ return (
+
+
+
Usage & Billing
+
+ Monitor your BrowserOS AI credit usage.
+
+
+
+
+
+
+
+ Credits
+
+
+
+
+
+ {credits}
+
+ / {total} daily
+
+
+
+
+
+
1 credit per request
+
Resets daily at midnight UTC
+ {data?.lastResetAt && (
+
Last reset: {new Date(data.lastResetAt).toLocaleDateString()}
+ )}
+
+
+
+
+
+
+ Need more credits?
+
+
+
+ Additional credit packages will be available soon.
+
+
+
+
+
+ )
+}
+```
+
+**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 `` block (after line 103, before closing ``):
+
+```tsx
+import { UsagePage } from './usage/UsagePage'
+
+// Add as new route:
+} />
+```
+
+**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**