mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
feat: integrate models.dev registry for model defaults (#425)
* feat: integrate models.dev registry for auto-populated model defaults Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: fall back to upstream provider for model registry lookup When the browseros meta-provider is used, the registry lookup now also tries the upstream provider (e.g., openrouter, anthropic) so that BrowserOS-hosted models get correct context window and image support defaults. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add Object.hasOwn guards to prevent prototype chain lookup Addresses Greptile review: bracket notation on the registry object could return prototype-chain properties for keys like __proto__ or constructor, bypassing the 404 guard in the route handler. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,7 @@
|
||||
"@ai-sdk/provider": "^3.0.8",
|
||||
"@browseros-ai/agent-sdk": "workspace:*",
|
||||
"@browseros/cdp-protocol": "workspace:*",
|
||||
"@browseros/models-dev": "workspace:*",
|
||||
"@browseros/shared": "workspace:*",
|
||||
"@google/gemini-cli-core": "^0.16.0",
|
||||
"@google/genai": "1.30.0",
|
||||
|
||||
36
apps/server/src/api/routes/models.ts
Normal file
36
apps/server/src/api/routes/models.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import {
|
||||
getAllProviders,
|
||||
getProviderModels,
|
||||
} from '@browseros/models-dev/registry'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
export function createModelsRoutes() {
|
||||
return new Hono()
|
||||
.get('/', (c) => {
|
||||
// Return provider summary with model counts
|
||||
const providers = getAllProviders()
|
||||
const summary = Object.fromEntries(
|
||||
Object.entries(providers).map(([id, p]) => [
|
||||
id,
|
||||
{
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
modelCount: Object.keys(p.models).length,
|
||||
},
|
||||
]),
|
||||
)
|
||||
return c.json(summary)
|
||||
})
|
||||
.get('/:provider', (c) => {
|
||||
const provider = c.req.param('provider')
|
||||
const info = getProviderModels(provider)
|
||||
if (!info) return c.json({ error: 'Provider not found' }, 404)
|
||||
return c.json(info)
|
||||
})
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { createGraphRoutes } from './routes/graph'
|
||||
import { createHealthRoute } from './routes/health'
|
||||
import { createKlavisRoutes } from './routes/klavis'
|
||||
import { createMcpRoutes } from './routes/mcp'
|
||||
import { createModelsRoutes } from './routes/models'
|
||||
import { createProviderRoutes } from './routes/provider'
|
||||
import { createSdkRoutes } from './routes/sdk'
|
||||
import { createShutdownRoute } from './routes/shutdown'
|
||||
@@ -108,6 +109,7 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
.route('/status', createStatusRoute({ controller }))
|
||||
.route('/soul', createSoulRoutes())
|
||||
.route('/test-provider', createProviderRoutes())
|
||||
.route('/models', createModelsRoutes())
|
||||
.route('/klavis', createKlavisRoutes({ browserosId: browserosId || '' }))
|
||||
.route(
|
||||
'/mcp',
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { getModelDefaults } from '@browseros/models-dev/registry'
|
||||
import { createAgentUIStreamResponse, type UIMessage } from 'ai'
|
||||
import { AiSdkAgent } from '../../agent/tool-loop/ai-sdk-agent'
|
||||
import { formatUserMessage } from '../../agent/tool-loop/format-message'
|
||||
@@ -40,6 +41,14 @@ export class ChatV2Service {
|
||||
|
||||
const sessionExecutionDir = await this.resolveSessionDir(request)
|
||||
|
||||
// Auto-populate model defaults from registry when not client-specified.
|
||||
// For the browseros meta-provider, also try the upstream provider.
|
||||
const modelDefaults =
|
||||
getModelDefaults(llmConfig.provider, llmConfig.model) ??
|
||||
(llmConfig.upstreamProvider
|
||||
? getModelDefaults(llmConfig.upstreamProvider, llmConfig.model)
|
||||
: undefined)
|
||||
|
||||
const agentConfig: ResolvedAgentConfig = {
|
||||
conversationId: request.conversationId,
|
||||
provider: llmConfig.provider,
|
||||
@@ -52,10 +61,14 @@ export class ChatV2Service {
|
||||
accessKeyId: llmConfig.accessKeyId,
|
||||
secretAccessKey: llmConfig.secretAccessKey,
|
||||
sessionToken: llmConfig.sessionToken,
|
||||
contextWindowSize: request.contextWindowSize,
|
||||
contextWindowSize:
|
||||
request.contextWindowSize ?? modelDefaults?.limit.context,
|
||||
userSystemPrompt: request.userSystemPrompt,
|
||||
sessionExecutionDir,
|
||||
supportsImages: request.supportsImages,
|
||||
supportsImages:
|
||||
request.supportsImages ??
|
||||
modelDefaults?.modalities.input.includes('image') ??
|
||||
true,
|
||||
chatMode: request.mode === 'chat',
|
||||
isScheduledTask: request.isScheduledTask,
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const ChatRequestSchema = AgentLLMConfigSchema.extend({
|
||||
userSystemPrompt: z.string().optional(),
|
||||
isScheduledTask: z.boolean().optional().default(false),
|
||||
userWorkingDir: z.string().min(1).optional(),
|
||||
supportsImages: z.boolean().optional().default(true),
|
||||
supportsImages: z.boolean().optional(),
|
||||
mode: z.enum(['chat', 'agent']).optional().default('agent'),
|
||||
previousConversation: z
|
||||
.union([
|
||||
|
||||
7
bun.lock
7
bun.lock
@@ -158,6 +158,7 @@
|
||||
"@ai-sdk/provider": "^3.0.8",
|
||||
"@browseros-ai/agent-sdk": "workspace:*",
|
||||
"@browseros/cdp-protocol": "workspace:*",
|
||||
"@browseros/models-dev": "workspace:*",
|
||||
"@browseros/shared": "workspace:*",
|
||||
"@google/gemini-cli-core": "^0.16.0",
|
||||
"@google/genai": "1.30.0",
|
||||
@@ -219,6 +220,10 @@
|
||||
"name": "@browseros/cdp-protocol",
|
||||
"version": "0.0.1",
|
||||
},
|
||||
"packages/models-dev": {
|
||||
"name": "@browseros/models-dev",
|
||||
"version": "0.0.1",
|
||||
},
|
||||
"packages/shared": {
|
||||
"name": "@browseros/shared",
|
||||
"version": "0.0.1",
|
||||
@@ -444,6 +449,8 @@
|
||||
|
||||
"@browseros/cdp-protocol": ["@browseros/cdp-protocol@workspace:packages/cdp-protocol"],
|
||||
|
||||
"@browseros/models-dev": ["@browseros/models-dev@workspace:packages/models-dev"],
|
||||
|
||||
"@browseros/server": ["@browseros/server@workspace:apps/server"],
|
||||
|
||||
"@browseros/shared": ["@browseros/shared@workspace:packages/shared"],
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"lint": "bunx biome check",
|
||||
"lint:fix": "bunx biome check --write --unsafe",
|
||||
"gen:cdp": "bun scripts/codegen/cdp-protocol.ts",
|
||||
"generate:models": "bun packages/models-dev/scripts/generate.ts",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"repository": "browseros-ai/BrowserOS-server",
|
||||
|
||||
19
packages/models-dev/package.json
Normal file
19
packages/models-dev/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@browseros/models-dev",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"generate": "bun scripts/generate.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
"./registry": {
|
||||
"types": "./src/registry.ts",
|
||||
"default": "./src/registry.ts"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./src/types.ts",
|
||||
"default": "./src/types.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
181
packages/models-dev/scripts/generate.ts
Normal file
181
packages/models-dev/scripts/generate.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Generates registry.json from a local models.dev clone.
|
||||
*
|
||||
* Usage:
|
||||
* bun packages/models-dev/scripts/generate.ts /path/to/models.dev/providers
|
||||
*/
|
||||
|
||||
import path from 'node:path'
|
||||
import type {
|
||||
ModelCost,
|
||||
ModelInfo,
|
||||
ModelRegistry,
|
||||
ProviderInfo,
|
||||
} from '../src/types'
|
||||
|
||||
// models.dev provider IDs → BrowserOS provider IDs
|
||||
const PROVIDER_MAP: Record<string, string> = {
|
||||
anthropic: 'anthropic',
|
||||
openai: 'openai',
|
||||
google: 'google',
|
||||
openrouter: 'openrouter',
|
||||
'ollama-cloud': 'ollama',
|
||||
}
|
||||
|
||||
const SOURCE_PROVIDERS = Object.keys(PROVIDER_MAP)
|
||||
|
||||
interface RawModel {
|
||||
id?: string
|
||||
name: string
|
||||
family?: string
|
||||
reasoning: boolean
|
||||
tool_call: boolean
|
||||
structured_output?: boolean
|
||||
attachment: boolean
|
||||
modalities: { input: string[]; output: string[] }
|
||||
limit: { context: number; input?: number; output: number }
|
||||
cost?: RawCost
|
||||
knowledge?: string
|
||||
status?: string
|
||||
release_date: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface RawCost {
|
||||
input: number
|
||||
output: number
|
||||
reasoning?: number
|
||||
cache_read?: number
|
||||
cache_write?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface RawProvider {
|
||||
name: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function extractCost(raw?: RawCost): ModelCost | undefined {
|
||||
if (!raw) return undefined
|
||||
const cost: ModelCost = { input: raw.input, output: raw.output }
|
||||
if (raw.reasoning !== undefined) cost.reasoning = raw.reasoning
|
||||
if (raw.cache_read !== undefined) cost.cache_read = raw.cache_read
|
||||
if (raw.cache_write !== undefined) cost.cache_write = raw.cache_write
|
||||
return cost
|
||||
}
|
||||
|
||||
function extractModel(raw: RawModel, modelId: string): ModelInfo {
|
||||
return {
|
||||
id: modelId,
|
||||
name: raw.name,
|
||||
...(raw.family && { family: raw.family }),
|
||||
reasoning: raw.reasoning,
|
||||
tool_call: raw.tool_call,
|
||||
...(raw.structured_output !== undefined && {
|
||||
structured_output: raw.structured_output,
|
||||
}),
|
||||
attachment: raw.attachment,
|
||||
modalities: raw.modalities,
|
||||
limit: {
|
||||
context: raw.limit.context,
|
||||
...(raw.limit.input !== undefined && { input: raw.limit.input }),
|
||||
output: raw.limit.output,
|
||||
},
|
||||
...(raw.cost && { cost: extractCost(raw.cost) }),
|
||||
...(raw.knowledge && { knowledge: raw.knowledge }),
|
||||
...(raw.status && { status: raw.status as ModelInfo['status'] }),
|
||||
release_date: raw.release_date,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadToml(filePath: string): Promise<Record<string, unknown>> {
|
||||
return import(filePath, { with: { type: 'toml' } }).then((mod) => mod.default)
|
||||
}
|
||||
|
||||
async function generateProvider(
|
||||
providersDir: string,
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
): Promise<ProviderInfo> {
|
||||
// Load provider metadata
|
||||
const providerToml = (await loadToml(
|
||||
path.join(providersDir, sourceId, 'provider.toml'),
|
||||
)) as unknown as RawProvider
|
||||
|
||||
const provider: ProviderInfo = {
|
||||
id: targetId,
|
||||
name: providerToml.name,
|
||||
models: {},
|
||||
}
|
||||
|
||||
// Scan all model TOML files
|
||||
const modelsDir = path.join(providersDir, sourceId, 'models')
|
||||
for await (const modelPath of new Bun.Glob('**/*.toml').scan({
|
||||
cwd: modelsDir,
|
||||
absolute: true,
|
||||
followSymlinks: true,
|
||||
})) {
|
||||
const modelId = path.relative(modelsDir, modelPath).slice(0, -5)
|
||||
try {
|
||||
const raw = (await loadToml(modelPath)) as unknown as RawModel
|
||||
provider.models[modelId] = extractModel(raw, modelId)
|
||||
} catch (err) {
|
||||
console.warn(`Skipping ${sourceId}/${modelId}: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const providersDir = process.argv[2]
|
||||
if (!providersDir) {
|
||||
console.error(
|
||||
'Usage: bun scripts/generate.ts <path-to-models.dev-providers>',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const absoluteDir = path.resolve(providersDir)
|
||||
console.log(`Reading from: ${absoluteDir}`)
|
||||
|
||||
const registry: ModelRegistry = {}
|
||||
|
||||
for (const sourceId of SOURCE_PROVIDERS) {
|
||||
const targetId = PROVIDER_MAP[sourceId]
|
||||
try {
|
||||
const provider = await generateProvider(absoluteDir, sourceId, targetId)
|
||||
const modelCount = Object.keys(provider.models).length
|
||||
console.log(` ${sourceId} → ${targetId}: ${modelCount} models`)
|
||||
registry[targetId] = provider
|
||||
} catch (err) {
|
||||
console.error(`Failed to process ${sourceId}: ${err}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Write registry
|
||||
const outputPath = path.join(
|
||||
import.meta.dir,
|
||||
'..',
|
||||
'src',
|
||||
'data',
|
||||
'registry.json',
|
||||
)
|
||||
await Bun.write(outputPath, JSON.stringify(registry, null, 2))
|
||||
|
||||
const totalModels = Object.values(registry).reduce(
|
||||
(sum, p) => sum + Object.keys(p.models).length,
|
||||
0,
|
||||
)
|
||||
console.log(`\nGenerated ${outputPath}`)
|
||||
console.log(
|
||||
` ${Object.keys(registry).length} providers, ${totalModels} models`,
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
7074
packages/models-dev/src/data/registry.json
Normal file
7074
packages/models-dev/src/data/registry.json
Normal file
File diff suppressed because it is too large
Load Diff
32
packages/models-dev/src/registry.ts
Normal file
32
packages/models-dev/src/registry.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Model registry lookup functions.
|
||||
* Data is sourced from models.dev and pre-built into registry.json.
|
||||
*/
|
||||
|
||||
import data from './data/registry.json'
|
||||
import type { ModelInfo, ModelRegistry, ProviderInfo } from './types'
|
||||
|
||||
const registry: ModelRegistry = data as ModelRegistry
|
||||
|
||||
export function getModelDefaults(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
): ModelInfo | undefined {
|
||||
if (!Object.hasOwn(registry, provider)) return undefined
|
||||
const p = registry[provider]
|
||||
if (!Object.hasOwn(p.models, modelId)) return undefined
|
||||
return p.models[modelId]
|
||||
}
|
||||
|
||||
export function getProviderModels(provider: string): ProviderInfo | undefined {
|
||||
if (!Object.hasOwn(registry, provider)) return undefined
|
||||
return registry[provider]
|
||||
}
|
||||
|
||||
export function getAllProviders(): Record<string, ProviderInfo> {
|
||||
return registry
|
||||
}
|
||||
46
packages/models-dev/src/types.ts
Normal file
46
packages/models-dev/src/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Model metadata types sourced from models.dev.
|
||||
*/
|
||||
|
||||
export interface ModelCost {
|
||||
input: number
|
||||
output: number
|
||||
reasoning?: number
|
||||
cache_read?: number
|
||||
cache_write?: number
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string
|
||||
name: string
|
||||
family?: string
|
||||
reasoning: boolean
|
||||
tool_call: boolean
|
||||
structured_output?: boolean
|
||||
attachment: boolean
|
||||
modalities: {
|
||||
input: string[]
|
||||
output: string[]
|
||||
}
|
||||
limit: {
|
||||
context: number
|
||||
input?: number
|
||||
output: number
|
||||
}
|
||||
cost?: ModelCost
|
||||
knowledge?: string
|
||||
status?: 'alpha' | 'beta' | 'deprecated'
|
||||
release_date: string
|
||||
}
|
||||
|
||||
export interface ProviderInfo {
|
||||
id: string
|
||||
name: string
|
||||
models: Record<string, ModelInfo>
|
||||
}
|
||||
|
||||
export type ModelRegistry = Record<string, ProviderInfo>
|
||||
8
packages/models-dev/tsconfig.json
Normal file
8
packages/models-dev/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user