mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 15:46:22 +00:00
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:
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"terminal.integrated.tabs.title": "${sequence} ${process}",
|
||||
"terminal.integrated.tabs.description": "${cwd}"
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@browseros/server",
|
||||
"version": "0.0.77",
|
||||
"version": "0.0.78",
|
||||
"description": "BrowserOS server",
|
||||
"type": "module",
|
||||
"main": "./src/index.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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -119,5 +119,6 @@ async function resolveBrowserOSConfig(
|
||||
apiKey: llmConfig.apiKey,
|
||||
baseUrl: llmConfig.baseUrl,
|
||||
upstreamProvider: llmConfig.providerType,
|
||||
browserosId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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**
|
||||
Reference in New Issue
Block a user