mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-18 02:57:47 +00:00
Compare commits
2 Commits
fix/schedu
...
fix/clean-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eb4c1d850 | ||
|
|
b608f37c89 |
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
|
||||
import { createMiddleware } from 'hono/factory'
|
||||
import type { RateLimiter } from '../../lib/rate-limiter/rate-limiter'
|
||||
import type { ChatRequest } from '../types'
|
||||
|
||||
interface RateLimitMiddlewareDeps {
|
||||
rateLimiter?: RateLimiter
|
||||
browserosId?: string
|
||||
}
|
||||
|
||||
type ChatValidationInput = {
|
||||
in: { json: ChatRequest }
|
||||
out: { json: ChatRequest }
|
||||
}
|
||||
|
||||
export function createBrowserosRateLimitMiddleware(
|
||||
deps: RateLimitMiddlewareDeps,
|
||||
) {
|
||||
return createMiddleware<object, '*', ChatValidationInput>(async (c, next) => {
|
||||
const { rateLimiter, browserosId } = deps
|
||||
|
||||
if (!rateLimiter || !browserosId) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const request = c.req.valid('json')
|
||||
|
||||
if (request.provider === LLM_PROVIDERS.BROWSEROS) {
|
||||
rateLimiter.check(browserosId)
|
||||
rateLimiter.record({
|
||||
conversationId: request.conversationId,
|
||||
browserosId,
|
||||
provider: request.provider,
|
||||
})
|
||||
}
|
||||
|
||||
return next()
|
||||
})
|
||||
}
|
||||
@@ -5,10 +5,8 @@ import type { Browser } from '../../browser/browser'
|
||||
import { KlavisClient } from '../../lib/clients/klavis/klavis-client'
|
||||
import { logger } from '../../lib/logger'
|
||||
import { metrics } from '../../lib/metrics'
|
||||
import type { RateLimiter } from '../../lib/rate-limiter/rate-limiter'
|
||||
import { Sentry } from '../../lib/sentry'
|
||||
import type { ToolRegistry } from '../../tools/tool-registry'
|
||||
import { createBrowserosRateLimitMiddleware } from '../middleware/rate-limit'
|
||||
import { ChatService } from '../services/chat-service'
|
||||
import { ChatRequestSchema } from '../types'
|
||||
import { ConversationIdParamSchema } from '../utils/validation'
|
||||
@@ -17,12 +15,11 @@ interface ChatRouteDeps {
|
||||
browser: Browser
|
||||
registry: ToolRegistry
|
||||
browserosId?: string
|
||||
rateLimiter?: RateLimiter
|
||||
aiSdkDevtoolsEnabled?: boolean
|
||||
}
|
||||
|
||||
export function createChatRoutes(deps: ChatRouteDeps) {
|
||||
const { browserosId, rateLimiter } = deps
|
||||
const { browserosId } = deps
|
||||
|
||||
const sessionStore = new SessionStore()
|
||||
const klavisClient = new KlavisClient()
|
||||
@@ -36,46 +33,41 @@ export function createChatRoutes(deps: ChatRouteDeps) {
|
||||
})
|
||||
|
||||
return new Hono()
|
||||
.post(
|
||||
'/',
|
||||
zValidator('json', ChatRequestSchema),
|
||||
createBrowserosRateLimitMiddleware({ rateLimiter, browserosId }),
|
||||
async (c) => {
|
||||
const request = c.req.valid('json')
|
||||
.post('/', zValidator('json', ChatRequestSchema), async (c) => {
|
||||
const request = c.req.valid('json')
|
||||
|
||||
// Sentry + metrics (HTTP concerns only)
|
||||
Sentry.getCurrentScope().setTag(
|
||||
'request-type',
|
||||
request.isScheduledTask ? 'schedule' : 'chat',
|
||||
)
|
||||
Sentry.setContext('request', {
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
baseUrl: request.baseUrl
|
||||
? (() => {
|
||||
try {
|
||||
return new URL(request.baseUrl).origin
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
: undefined,
|
||||
})
|
||||
// Sentry + metrics (HTTP concerns only)
|
||||
Sentry.getCurrentScope().setTag(
|
||||
'request-type',
|
||||
request.isScheduledTask ? 'schedule' : 'chat',
|
||||
)
|
||||
Sentry.setContext('request', {
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
baseUrl: request.baseUrl
|
||||
? (() => {
|
||||
try {
|
||||
return new URL(request.baseUrl).origin
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
: undefined,
|
||||
})
|
||||
|
||||
metrics.log('chat.request', {
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
})
|
||||
metrics.log('chat.request', {
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
})
|
||||
|
||||
logger.info('Chat request received', {
|
||||
conversationId: request.conversationId,
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
})
|
||||
logger.info('Chat request received', {
|
||||
conversationId: request.conversationId,
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
})
|
||||
|
||||
return service.processMessage(request, c.req.raw.signal)
|
||||
},
|
||||
)
|
||||
return service.processMessage(request, c.req.raw.signal)
|
||||
})
|
||||
.delete(
|
||||
'/:conversationId',
|
||||
zValidator('param', ConversationIdParamSchema),
|
||||
|
||||
@@ -71,7 +71,6 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
browserosId,
|
||||
executionDir,
|
||||
resourcesDir,
|
||||
rateLimiter,
|
||||
version,
|
||||
browser,
|
||||
controller,
|
||||
@@ -161,7 +160,6 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
browser,
|
||||
registry,
|
||||
browserosId,
|
||||
rateLimiter,
|
||||
aiSdkDevtoolsEnabled: config.aiSdkDevtoolsEnabled,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -16,7 +16,6 @@ import { LLMConfigSchema } from '@browseros/shared/schemas/llm'
|
||||
import { z } from 'zod'
|
||||
import type { ControllerBackend } from '../browser/backends/controller'
|
||||
import type { Browser } from '../browser/browser'
|
||||
import type { RateLimiter } from '../lib/rate-limiter/rate-limiter'
|
||||
import type { ToolRegistry } from '../tools/tool-registry'
|
||||
|
||||
// Re-export browser context types for consumers
|
||||
@@ -99,8 +98,6 @@ export interface HttpServerConfig {
|
||||
browserosId?: string
|
||||
executionDir: string
|
||||
resourcesDir: string
|
||||
rateLimiter?: RateLimiter
|
||||
|
||||
codegenServiceUrl?: string
|
||||
aiSdkDevtoolsEnabled?: boolean
|
||||
|
||||
|
||||
@@ -5,15 +5,6 @@
|
||||
*/
|
||||
import type { Database } from 'bun:sqlite'
|
||||
|
||||
// id is the conversation_id - using it as PK ensures same conversation is only counted once
|
||||
const RATE_LIMITER_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS rate_limiter (
|
||||
id TEXT PRIMARY KEY,
|
||||
browseros_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`
|
||||
|
||||
const IDENTITY_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS identity (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
@@ -36,7 +27,6 @@ CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||||
)`
|
||||
|
||||
export function initSchema(db: Database): void {
|
||||
db.exec(RATE_LIMITER_TABLE)
|
||||
db.exec(IDENTITY_TABLE)
|
||||
db.exec(OAUTH_TOKENS_TABLE)
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { HttpAgentError } from '../../agent/errors'
|
||||
|
||||
export class RateLimitError extends HttpAgentError {
|
||||
constructor(
|
||||
public used: number,
|
||||
public limit: number,
|
||||
) {
|
||||
super(
|
||||
`Daily limit reached (${used}/${limit}). Add your own API key for unlimited usage. https://dub.sh/browseros-usage-limit`,
|
||||
429,
|
||||
'RATE_LIMIT_EXCEEDED',
|
||||
)
|
||||
}
|
||||
|
||||
override toJSON() {
|
||||
return {
|
||||
error: {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
code: this.code,
|
||||
statusCode: this.statusCode,
|
||||
used: this.used,
|
||||
limit: this.limit,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Rate limit configuration fetching from remote config service.
|
||||
*/
|
||||
|
||||
import { RATE_LIMITS } from '@browseros/shared/constants/limits'
|
||||
|
||||
import { INLINED_ENV } from '../../env'
|
||||
import { fetchBrowserOSConfig } from '../clients/gateway'
|
||||
import { logger } from '../logger'
|
||||
|
||||
export async function fetchDailyRateLimit(
|
||||
browserosId: string,
|
||||
): Promise<number> {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
logger.info('Test mode: rate limiting disabled')
|
||||
return RATE_LIMITS.TEST_DAILY
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
logger.info('Dev mode: using dev rate limit', {
|
||||
dailyRateLimit: RATE_LIMITS.DEV_DAILY,
|
||||
})
|
||||
return RATE_LIMITS.DEV_DAILY
|
||||
}
|
||||
|
||||
const configUrl = INLINED_ENV.BROWSEROS_CONFIG_URL
|
||||
if (!configUrl) {
|
||||
logger.info('No BROWSEROS_CONFIG_URL, using default rate limit', {
|
||||
dailyRateLimit: RATE_LIMITS.DEFAULT_DAILY,
|
||||
})
|
||||
return RATE_LIMITS.DEFAULT_DAILY
|
||||
}
|
||||
|
||||
try {
|
||||
const browserosConfig = await fetchBrowserOSConfig(configUrl, browserosId)
|
||||
const defaultProvider = browserosConfig.providers.find(
|
||||
(p) => p.name === 'default',
|
||||
)
|
||||
const dailyRateLimit =
|
||||
defaultProvider?.dailyRateLimit ?? RATE_LIMITS.DEFAULT_DAILY
|
||||
|
||||
logger.info('Rate limit config fetched', { dailyRateLimit })
|
||||
return dailyRateLimit
|
||||
} catch (error) {
|
||||
logger.warn('Failed to fetch rate limit config, using default', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
dailyRateLimit: RATE_LIMITS.DEFAULT_DAILY,
|
||||
})
|
||||
return RATE_LIMITS.DEFAULT_DAILY
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Database } from 'bun:sqlite'
|
||||
import { RATE_LIMITS } from '@browseros/shared/constants/limits'
|
||||
|
||||
import { logger } from '../logger'
|
||||
import { metrics } from '../metrics'
|
||||
|
||||
import { RateLimitError } from './errors'
|
||||
|
||||
export interface RecordParams {
|
||||
conversationId: string
|
||||
browserosId: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export class RateLimiter {
|
||||
private countStmt: ReturnType<Database['prepare']>
|
||||
private insertStmt: ReturnType<Database['prepare']>
|
||||
private dailyRateLimit: number
|
||||
|
||||
constructor(
|
||||
db: Database,
|
||||
dailyRateLimit: number = RATE_LIMITS.DEFAULT_DAILY,
|
||||
) {
|
||||
this.dailyRateLimit = dailyRateLimit
|
||||
this.countStmt = db.prepare(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM rate_limiter
|
||||
WHERE browseros_id = ?
|
||||
AND date(created_at) = date('now')
|
||||
`)
|
||||
|
||||
// INSERT OR IGNORE: duplicate conversation_ids are silently ignored
|
||||
// This ensures the same conversation is only counted once for rate limiting
|
||||
this.insertStmt = db.prepare(`
|
||||
INSERT OR IGNORE INTO rate_limiter
|
||||
(id, browseros_id, provider)
|
||||
VALUES (?, ?, ?)
|
||||
`)
|
||||
}
|
||||
|
||||
check(browserosId: string): void {
|
||||
const count = this.getTodayCount(browserosId)
|
||||
if (count >= this.dailyRateLimit) {
|
||||
logger.warn('Rate limit exceeded', {
|
||||
browserosId,
|
||||
count,
|
||||
dailyRateLimit: this.dailyRateLimit,
|
||||
})
|
||||
metrics.log('rate_limit.triggered', {
|
||||
count,
|
||||
daily_limit: this.dailyRateLimit,
|
||||
})
|
||||
throw new RateLimitError(count, this.dailyRateLimit)
|
||||
}
|
||||
}
|
||||
|
||||
record(params: RecordParams): void {
|
||||
const { conversationId, browserosId, provider } = params
|
||||
this.insertStmt.run(conversationId, browserosId, provider)
|
||||
}
|
||||
|
||||
private getTodayCount(browserosId: string): number {
|
||||
const row = this.countStmt.get(browserosId) as { count: number } | null
|
||||
return row?.count ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
export { RateLimitError } from './errors'
|
||||
@@ -29,8 +29,6 @@ import { identity } from './lib/identity'
|
||||
import { logger } from './lib/logger'
|
||||
import { metrics } from './lib/metrics'
|
||||
import { isPortInUseError } from './lib/port-binding'
|
||||
import { fetchDailyRateLimit } from './lib/rate-limiter/fetch-config'
|
||||
import { RateLimiter } from './lib/rate-limiter/rate-limiter'
|
||||
import { Sentry } from './lib/sentry'
|
||||
import { seedSoulTemplate } from './lib/soul'
|
||||
import { migrateBuiltinSkills } from './skills/migrate'
|
||||
@@ -59,8 +57,6 @@ export class Application {
|
||||
|
||||
await this.initCoreServices()
|
||||
|
||||
const dailyRateLimit = await fetchDailyRateLimit(identity.getBrowserOSId())
|
||||
|
||||
const controller = new ControllerBackend({
|
||||
port: this.config.extensionPort,
|
||||
})
|
||||
@@ -104,7 +100,6 @@ export class Application {
|
||||
browserosId: identity.getBrowserOSId(),
|
||||
executionDir: this.config.executionDir,
|
||||
resourcesDir: this.config.resourcesDir,
|
||||
rateLimiter: new RateLimiter(this.getDb(), dailyRateLimit),
|
||||
codegenServiceUrl: this.config.codegenServiceUrl,
|
||||
aiSdkDevtoolsEnabled: this.config.aiSdkDevtoolsEnabled,
|
||||
|
||||
@@ -274,13 +269,4 @@ export class Application {
|
||||
logger.info(` HTTP Server: http://127.0.0.1:${this.config.serverPort}`)
|
||||
logger.info('')
|
||||
}
|
||||
|
||||
private getDb(): Database {
|
||||
if (!this.db) {
|
||||
throw new Error(
|
||||
'Database not initialized. Call initCoreServices() first.',
|
||||
)
|
||||
}
|
||||
return this.db
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Integration tests for RateLimiter
|
||||
* Uses in-memory SQLite to test actual database behavior
|
||||
*/
|
||||
|
||||
import { Database } from 'bun:sqlite'
|
||||
import { beforeEach, describe, expect, it } from 'bun:test'
|
||||
|
||||
import {
|
||||
RateLimitError,
|
||||
RateLimiter,
|
||||
} from '../../src/agent/rate-limiter/rate-limiter'
|
||||
|
||||
const DAILY_RATE_LIMIT_TEST = 3
|
||||
|
||||
function createTestDb(): Database {
|
||||
const db = new Database(':memory:')
|
||||
db.exec('PRAGMA journal_mode = WAL')
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS rate_limiter (
|
||||
id TEXT PRIMARY KEY,
|
||||
browseros_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`)
|
||||
return db
|
||||
}
|
||||
|
||||
describe('RateLimiter', () => {
|
||||
let db: Database
|
||||
let rateLimiter: RateLimiter
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDb()
|
||||
rateLimiter = new RateLimiter(db, DAILY_RATE_LIMIT_TEST)
|
||||
})
|
||||
|
||||
describe('check()', () => {
|
||||
it('allows first 3 conversations (check before record)', () => {
|
||||
const browserosId = 'test-browseros-id'
|
||||
|
||||
// Simulates real flow: check() then record() for each conversation
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
expect(() => rateLimiter.check(browserosId)).not.toThrow()
|
||||
rateLimiter.record({
|
||||
conversationId: `conv-${i}`,
|
||||
browserosId,
|
||||
provider: 'browseros',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('blocks 4th conversation with RateLimitError', () => {
|
||||
const browserosId = 'test-browseros-id'
|
||||
|
||||
// Use up all 3 slots
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
rateLimiter.check(browserosId)
|
||||
rateLimiter.record({
|
||||
conversationId: `conv-${i}`,
|
||||
browserosId,
|
||||
provider: 'browseros',
|
||||
})
|
||||
}
|
||||
|
||||
// 4th should be blocked
|
||||
expect(() => rateLimiter.check(browserosId)).toThrow(RateLimitError)
|
||||
|
||||
try {
|
||||
rateLimiter.check(browserosId)
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(RateLimitError)
|
||||
const rateLimitError = error as RateLimitError
|
||||
expect(rateLimitError.used).toBe(3)
|
||||
expect(rateLimitError.limit).toBe(3)
|
||||
expect(rateLimitError.statusCode).toBe(429)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('record() with duplicate conversation IDs', () => {
|
||||
it('ignores duplicate conversation IDs (same conversation counted once)', () => {
|
||||
const browserosId = 'test-browseros-id'
|
||||
const sameConversationId = 'duplicate-conv-id'
|
||||
|
||||
// Record the same conversation 5 times
|
||||
for (let i = 0; i < 5; i++) {
|
||||
rateLimiter.record({
|
||||
conversationId: sameConversationId,
|
||||
browserosId,
|
||||
provider: 'browseros',
|
||||
})
|
||||
}
|
||||
|
||||
// Should still pass - only counts as 1 conversation
|
||||
expect(() => rateLimiter.check(browserosId)).not.toThrow()
|
||||
|
||||
// Add 2 more unique conversations (total 3)
|
||||
rateLimiter.record({
|
||||
conversationId: 'unique-conv-1',
|
||||
browserosId,
|
||||
provider: 'browseros',
|
||||
})
|
||||
rateLimiter.record({
|
||||
conversationId: 'unique-conv-2',
|
||||
browserosId,
|
||||
provider: 'browseros',
|
||||
})
|
||||
|
||||
// Now at limit (3 unique conversations)
|
||||
expect(() => rateLimiter.check(browserosId)).toThrow(RateLimitError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('separate limits per browserosId', () => {
|
||||
it('tracks limits independently for different users', () => {
|
||||
const user1 = 'browseros-user-1'
|
||||
const user2 = 'browseros-user-2'
|
||||
|
||||
// User 1 uses all 3 conversations
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
rateLimiter.record({
|
||||
conversationId: `user1-conv-${i}`,
|
||||
browserosId: user1,
|
||||
provider: 'browseros',
|
||||
})
|
||||
}
|
||||
|
||||
// User 1 is blocked
|
||||
expect(() => rateLimiter.check(user1)).toThrow(RateLimitError)
|
||||
|
||||
// User 2 should still have full quota
|
||||
expect(() => rateLimiter.check(user2)).not.toThrow()
|
||||
|
||||
// User 2 can use their quota
|
||||
rateLimiter.record({
|
||||
conversationId: 'user2-conv-1',
|
||||
browserosId: user2,
|
||||
provider: 'browseros',
|
||||
})
|
||||
expect(() => rateLimiter.check(user2)).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -99,12 +99,6 @@ describe('Application.start', () => {
|
||||
log: mock(() => {}),
|
||||
},
|
||||
}))
|
||||
mock.module('../src/lib/rate-limiter/fetch-config', () => ({
|
||||
fetchDailyRateLimit: mock(async () => 100),
|
||||
}))
|
||||
mock.module('../src/lib/rate-limiter/rate-limiter', () => ({
|
||||
RateLimiter: class {},
|
||||
}))
|
||||
mock.module('../src/lib/sentry', () => ({
|
||||
Sentry: {
|
||||
setContext: mock(() => {}),
|
||||
|
||||
@@ -6,12 +6,6 @@
|
||||
* Centralized limits and thresholds.
|
||||
*/
|
||||
|
||||
export const RATE_LIMITS = {
|
||||
DEFAULT_DAILY: 5,
|
||||
DEV_DAILY: 100,
|
||||
TEST_DAILY: Infinity,
|
||||
} as const
|
||||
|
||||
export const AGENT_LIMITS = {
|
||||
MAX_TURNS: 100,
|
||||
DEFAULT_CONTEXT_WINDOW: 200_000,
|
||||
|
||||
Reference in New Issue
Block a user