Files
Starlight_Lancher/scripts/axolotl/sync-lobehub-models.mjs

311 lines
10 KiB
JavaScript

import { execFileSync } from 'node:child_process'
import fs from 'node:fs/promises'
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
import { stripTypeScriptTypes } from 'node:module'
import { dirname, extname, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import vm from 'node:vm'
const REPOSITORY_ROOT = fileURLToPath(new URL('../../', import.meta.url))
const CATALOG_PATH = resolve(REPOSITORY_ROOT, 'packages/app-lib/src/api/lobehub_text_models.json')
const RUST_API_PATH = resolve(REPOSITORY_ROOT, 'packages/app-lib/src/api/ai.rs')
const SOURCE_PATTERN = /^LobeHub ([0-9a-f]{40}) model-bank chat models$/
const RUST_SOURCE_PATTERN = /const CATALOG_SOURCE: &str = "LobeHub ([0-9a-f]{40})";/g
// Sponsored providers are paid partner listings that must survive every
// synchronization, even if a future rewrite of ai.rs drops upstream providers.
// Keep this list in sync with SPONSORED_PROVIDERS in packages/app-lib/src/api/ai.rs.
const SPONSORED_PROVIDER_IDS = ['codeflow']
function assertSponsoredProvidersPreserved(rustSource) {
for (const providerId of SPONSORED_PROVIDER_IDS) {
const pattern = new RegExp(`provider!\\s*\\(\\s*"${providerId}"`)
if (!pattern.test(rustSource)) {
throw new Error(
`Sponsored provider ${providerId} is missing from ai.rs; refusing to rewrite it`,
)
}
}
}
function usage() {
console.log(`Usage: node --experimental-vm-modules scripts/axolotl/sync-lobehub-models.mjs \\
--upstream <lobehub checkout> [--commit <40-character SHA>] [--check]`)
}
function parseArguments(arguments_) {
const options = { check: false, commit: undefined, upstream: undefined }
for (let index = 0; index < arguments_.length; index++) {
const argument = arguments_[index]
switch (argument) {
case '--check':
options.check = true
break
case '--commit':
case '--upstream': {
const value = arguments_[++index]
if (!value) throw new Error(`${argument} requires a value`)
options[argument.slice(2)] = value
break
}
case '--help':
case '-h':
usage()
process.exit(0)
default:
throw new Error(`Unknown argument: ${argument}`)
}
}
if (!options.upstream) throw new Error('--upstream is required')
return options
}
function requireObject(value, label) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} must be an object`)
}
return value
}
function countModels(providers) {
return Object.values(providers).reduce((total, models) => total + models.length, 0)
}
function resolveCommit(upstreamRoot, requestedCommit) {
const commit =
requestedCommit ??
execFileSync('git', ['-C', upstreamRoot, 'rev-parse', 'HEAD'], {
encoding: 'utf8',
}).trim()
if (!/^[0-9a-f]{40}$/.test(commit)) {
throw new Error(`Invalid LobeHub commit SHA: ${commit}`)
}
return commit
}
function createModelBankLoader(upstreamRoot) {
if (typeof vm.SourceTextModule !== 'function') {
throw new Error('Node.js must be run with --experimental-vm-modules')
}
const sourceRoot = realpathSync(resolve(upstreamRoot, 'packages/model-bank/src'))
const context = vm.createContext(Object.create(null), {
codeGeneration: { strings: false, wasm: false },
name: 'lobehub-model-bank',
})
const moduleCache = new Map()
const schema = vm.runInContext(
`(() => {
let schema
schema = new Proxy(function () { return schema }, { get() { return schema } })
return schema
})()`,
context,
)
const zodModule = new vm.SyntheticModule(
['z'],
function () {
this.setExport('z', schema)
},
{ context, identifier: 'sandbox:zod' },
)
const typeFestModule = new vm.SyntheticModule([], function () {}, {
context,
identifier: 'sandbox:type-fest',
})
function resolveLocalImport(specifier, parentPath) {
const basePath = resolve(dirname(parentPath), specifier)
const candidates = extname(basePath)
? [basePath]
: [`${basePath}.ts`, join(basePath, 'index.ts')]
for (const candidate of candidates) {
if (!existsSync(candidate) || !statSync(candidate).isFile()) continue
const canonicalPath = realpathSync(candidate)
const relativePath = relative(sourceRoot, canonicalPath)
if (relativePath === '..' || relativePath.startsWith(`..${sep}`)) {
throw new Error(`Import escapes model-bank/src: ${specifier}`)
}
return canonicalPath
}
throw new Error(`Unable to resolve ${specifier} from ${parentPath}`)
}
function getModule(filePath) {
const cached = moduleCache.get(filePath)
if (cached) return cached
const code = stripTypeScriptTypes(readFileSync(filePath, 'utf8'), {
mode: 'transform',
sourceUrl: pathToFileURL(filePath).href,
})
const module = new vm.SourceTextModule(code, {
context,
identifier: pathToFileURL(filePath).href,
importModuleDynamically() {
throw new Error('Dynamic imports are not allowed in the LobeHub model bank')
},
})
moduleCache.set(filePath, module)
return module
}
function linker(specifier, referencingModule) {
if (specifier === 'zod') return zodModule
if (specifier === 'type-fest') return typeFestModule
if (!specifier.startsWith('.')) {
throw new Error(`External import is not allowed in the model bank: ${specifier}`)
}
return getModule(resolveLocalImport(specifier, fileURLToPath(referencingModule.identifier)))
}
return async function loadModels() {
const entryPath = realpathSync(join(sourceRoot, 'aiModels/index.ts'))
const entry = getModule(entryPath)
await entry.link(linker)
await entry.evaluate({ timeout: 20_000 })
return entry.namespace.LOBE_DEFAULT_MODEL_LIST
}
}
function validateCurrentCatalog(catalog, rustSource) {
requireObject(catalog, 'Current model catalog')
const providers = requireObject(catalog.providers, 'Current model catalog providers')
const sourceMatch = SOURCE_PATTERN.exec(catalog.source)
if (!sourceMatch) throw new Error('Current model catalog has an invalid source')
const rustMatches = [...rustSource.matchAll(RUST_SOURCE_PATTERN)]
if (rustMatches.length !== 1) {
throw new Error('Expected exactly one CATALOG_SOURCE constant in ai.rs')
}
if (rustMatches[0][1] !== sourceMatch[1]) {
throw new Error('The JSON and Rust catalog source commits do not match')
}
for (const [providerId, models] of Object.entries(providers)) {
if (!/^[a-z0-9]+$/.test(providerId) || !Array.isArray(models)) {
throw new Error(`Invalid current catalog provider: ${providerId}`)
}
}
return providers
}
function buildCatalog(models, currentProviders) {
if (!Array.isArray(models)) {
throw new Error('LOBE_DEFAULT_MODEL_LIST must be an array')
}
const providers = Object.fromEntries(
Object.keys(currentProviders).map((providerId) => [providerId, []]),
)
const seen = new Set()
let upstreamChatCount = 0
for (const model of models) {
requireObject(model, 'LobeHub model')
if (model.type !== 'chat') continue
upstreamChatCount++
if (typeof model.providerId !== 'string' || !model.providerId) {
throw new Error('LobeHub chat model has no providerId')
}
if (typeof model.id !== 'string' || !model.id.trim()) {
throw new Error(`LobeHub ${model.providerId} chat model has no id`)
}
if (typeof model.enabled !== 'boolean') {
throw new Error(`LobeHub model ${model.providerId}/${model.id} has invalid enabled state`)
}
if (!Object.hasOwn(providers, model.providerId)) continue
const key = `${model.providerId}\0${model.id}`
if (seen.has(key)) throw new Error(`Duplicate LobeHub model: ${model.providerId}/${model.id}`)
seen.add(key)
const displayName = model.displayName || model.id
if (typeof displayName !== 'string' || !displayName.trim()) {
throw new Error(`LobeHub model ${model.providerId}/${model.id} has no display name`)
}
providers[model.providerId].push({
id: model.id,
name: displayName,
enabled: model.enabled,
})
}
for (const providerId of SPONSORED_PROVIDER_IDS) {
const sponsorModels = currentProviders[providerId]
if (!sponsorModels?.length) continue
providers[providerId] = [...sponsorModels]
console.log(
`Preserved ${sponsorModels.length} sponsored model(s) for ${providerId}`,
)
}
const previousCount = countModels(currentProviders)
const synchronizedCount = countModels(providers)
const minimumCount = Math.max(100, Math.floor(previousCount * 0.7))
const maximumCount = Math.max(500, Math.ceil(previousCount * 2))
if (synchronizedCount < minimumCount || synchronizedCount > maximumCount) {
throw new Error(
`Refusing suspicious model count change: ${previousCount} -> ${synchronizedCount}`,
)
}
for (const [providerId, previousModels] of Object.entries(currentProviders)) {
if (previousModels.length > 0 && providers[providerId].length === 0) {
throw new Error(`Refusing to empty existing provider ${providerId}`)
}
}
return { providers, synchronizedCount, upstreamChatCount }
}
const options = parseArguments(process.argv.slice(2))
const upstreamRoot = realpathSync(resolve(options.upstream))
const commit = resolveCommit(upstreamRoot, options.commit)
const [catalogText, rustSource] = await Promise.all([
fs.readFile(CATALOG_PATH, 'utf8'),
fs.readFile(RUST_API_PATH, 'utf8'),
])
assertSponsoredProvidersPreserved(rustSource)
const currentCatalog = JSON.parse(catalogText)
const currentProviders = validateCurrentCatalog(currentCatalog, rustSource)
const loadModels = createModelBankLoader(upstreamRoot)
const { providers, synchronizedCount, upstreamChatCount } = buildCatalog(
await loadModels(),
currentProviders,
)
const providersChanged = JSON.stringify(providers) !== JSON.stringify(currentProviders)
if (!providersChanged) {
console.log(
`LobeHub ${commit} has no model changes for ${Object.keys(providers).length} supported providers (${synchronizedCount}/${upstreamChatCount} chat models).`,
)
process.exit(0)
}
const nextCatalogText = `${JSON.stringify(
{
source: `LobeHub ${commit} model-bank chat models`,
providers,
},
null,
'\t',
)}\n`
const nextRustSource = rustSource.replace(
RUST_SOURCE_PATTERN,
`const CATALOG_SOURCE: &str = "LobeHub ${commit}";`,
)
assertSponsoredProvidersPreserved(nextRustSource)
if (options.check) {
console.error(
`Bundled model catalog is stale: LobeHub ${commit} has ${synchronizedCount}/${upstreamChatCount} applicable chat models.`,
)
process.exit(1)
}
await Promise.all([
fs.writeFile(CATALOG_PATH, nextCatalogText),
fs.writeFile(RUST_API_PATH, nextRustSource),
])
console.log(
`Synchronized ${synchronizedCount}/${upstreamChatCount} LobeHub chat models from ${commit} across ${Object.keys(providers).length} supported providers.`,
)