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**