153 lines
8.2 KiB
TypeScript
153 lines
8.2 KiB
TypeScript
import type { Plugin } from 'vite'
|
|
|
|
// Dev-only in-memory mock of the /api surface the Fallback page needs, so the
|
|
// frontend can be exercised without a running backend. Enabled only when the
|
|
// dev server is started with MOCK_API=1 (see vite.config.ts) — never bundled.
|
|
|
|
const PRESETS: Record<string, { reliability: number; speed: number; intelligence: number }> = {
|
|
balanced: { reliability: 0.5, speed: 0.25, intelligence: 0.25 },
|
|
smartest: { reliability: 0.35, speed: 0.1, intelligence: 0.55 },
|
|
fastest: { reliability: 0.35, speed: 0.55, intelligence: 0.1 },
|
|
reliable: { reliability: 0.7, speed: 0.15, intelligence: 0.15 },
|
|
}
|
|
|
|
interface MockModel {
|
|
modelDbId: number
|
|
platform: string
|
|
modelId: string
|
|
displayName: string
|
|
sizeLabel: string
|
|
intelligenceRank: number
|
|
speedRank: number
|
|
monthlyTokenBudget: string
|
|
supportsVision: boolean
|
|
rpmLimit: number | null
|
|
rpdLimit: number | null
|
|
enabled: boolean
|
|
priority: number
|
|
reliability: number
|
|
speed: number
|
|
intelligence: number
|
|
headroom: number
|
|
rateLimit: number
|
|
totalRequests: number
|
|
}
|
|
|
|
let strategy = 'balanced'
|
|
// User-tuned vector for the 'custom' strategy (kept normalized, like the server).
|
|
let customWeights = { ...PRESETS.balanced }
|
|
|
|
function activeWeights() {
|
|
if (strategy === 'custom') return customWeights
|
|
return PRESETS[strategy] ?? PRESETS.balanced
|
|
}
|
|
|
|
const models: MockModel[] = [
|
|
{ modelDbId: 1, platform: 'google', modelId: 'gemini-2.5-pro', displayName: 'Gemini 2.5 Pro', sizeLabel: 'Frontier', intelligenceRank: 1, speedRank: 8, monthlyTokenBudget: '~12M', supportsVision: true, rpmLimit: 5, rpdLimit: 100, enabled: true, priority: 1, reliability: 0.94, speed: 0.55, intelligence: 1.0, headroom: 1, rateLimit: 1, totalRequests: 412 },
|
|
{ modelDbId: 2, platform: 'groq', modelId: 'openai/gpt-oss-120b', displayName: 'GPT-OSS 120B', sizeLabel: 'Frontier', intelligenceRank: 6, speedRank: 2, monthlyTokenBudget: '~6M', supportsVision: false, rpmLimit: 30, rpdLimit: 1000, enabled: true, priority: 2, reliability: 0.9, speed: 0.98, intelligence: 0.72, headroom: 1, rateLimit: 0.7, totalRequests: 833 },
|
|
{ modelDbId: 3, platform: 'cerebras', modelId: 'llama-3.3-70b', displayName: 'Llama 3.3 70B', sizeLabel: 'Large', intelligenceRank: 4, speedRank: 1, monthlyTokenBudget: '~50M', supportsVision: false, rpmLimit: 30, rpdLimit: 14400, enabled: true, priority: 3, reliability: 0.88, speed: 1.0, intelligence: 0.5, headroom: 1, rateLimit: 1, totalRequests: 256 },
|
|
{ modelDbId: 4, platform: 'google', modelId: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash', sizeLabel: 'Large', intelligenceRank: 4, speedRank: 5, monthlyTokenBudget: '~3M', supportsVision: true, rpmLimit: 10, rpdLimit: 20, enabled: true, priority: 4, reliability: 0.97, speed: 0.78, intelligence: 0.52, headroom: 0.45, rateLimit: 1, totalRequests: 90 },
|
|
{ modelDbId: 5, platform: 'nvidia', modelId: 'llama-4-scout', displayName: 'Llama 4 Scout', sizeLabel: 'Medium', intelligenceRank: 7, speedRank: 4, monthlyTokenBudget: '~30M', supportsVision: true, rpmLimit: 40, rpdLimit: null, enabled: true, priority: 5, reliability: 0.7, speed: 0.83, intelligence: 0.33, headroom: 1, rateLimit: 1, totalRequests: 47 },
|
|
{ modelDbId: 6, platform: 'openrouter', modelId: 'deepseek/deepseek-v3.1:free', displayName: 'DeepSeek V3.1', sizeLabel: 'Frontier', intelligenceRank: 2, speedRank: 10, monthlyTokenBudget: '~6M', supportsVision: false, rpmLimit: 20, rpdLimit: 200, enabled: false, priority: 6, reliability: 0.5, speed: 0.4, intelligence: 0.67, headroom: 1, rateLimit: 1, totalRequests: 0 },
|
|
{ modelDbId: 7, platform: 'mistral', modelId: 'mistral-large', displayName: 'Mistral Large', sizeLabel: 'Large', intelligenceRank: 5, speedRank: 6, monthlyTokenBudget: '~15M', supportsVision: false, rpmLimit: 60, rpdLimit: 500, enabled: true, priority: 7, reliability: 0.82, speed: 0.6, intelligence: 0.45, headroom: 1, rateLimit: 1, totalRequests: 178 },
|
|
]
|
|
|
|
function budgetTokens(label: string): number {
|
|
const m = label.match(/~?([\d.]+)(?:-([\d.]+))?([MK])?/)
|
|
if (!m) return 0
|
|
const n = parseFloat(m[2] ?? m[1])
|
|
const unit = m[3] === 'M' ? 1_000_000 : m[3] === 'K' ? 1_000 : 1
|
|
return n * unit
|
|
}
|
|
|
|
function score(m: MockModel): number {
|
|
const w = activeWeights()
|
|
const base = w.reliability * m.reliability + w.speed * m.speed + w.intelligence * m.intelligence
|
|
return base * m.headroom * m.rateLimit
|
|
}
|
|
|
|
function readBody(req: any): Promise<any> {
|
|
return new Promise(resolve => {
|
|
let data = ''
|
|
req.on('data', (c: any) => { data += c })
|
|
req.on('end', () => { try { resolve(JSON.parse(data || '{}')) } catch { resolve({}) } })
|
|
})
|
|
}
|
|
|
|
export function mockApiPlugin(): Plugin {
|
|
return {
|
|
name: 'freellmapi-dev-mock',
|
|
configureServer(server) {
|
|
server.middlewares.use(async (req: any, res: any, next: any) => {
|
|
const url = (req.url || '').split('?')[0]
|
|
if (!url.startsWith('/api/')) return next()
|
|
const send = (obj: any, status = 200) => {
|
|
res.statusCode = status
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.end(JSON.stringify(obj))
|
|
}
|
|
|
|
// Auth gate — always "logged in" in mock mode.
|
|
if (url === '/api/auth/status') return send({ needsSetup: false, authenticated: true, email: 'dev@local' })
|
|
if (url === '/api/ping') return send({ status: 'ok', timestamp: new Date().toISOString() })
|
|
|
|
if (url === '/api/fallback' && req.method === 'GET') {
|
|
const rows = [...models].sort((a, b) => a.priority - b.priority).map(m => ({
|
|
modelDbId: m.modelDbId, priority: m.priority, effectivePriority: m.priority,
|
|
penalty: m.rateLimit < 1 ? Math.round((1 - m.rateLimit) * 10) : 0,
|
|
rateLimitHits: m.rateLimit < 1 ? 3 : 0,
|
|
enabled: m.enabled, platform: m.platform, modelId: m.modelId, displayName: m.displayName,
|
|
intelligenceRank: m.intelligenceRank, speedRank: m.speedRank, sizeLabel: m.sizeLabel,
|
|
rpmLimit: m.rpmLimit, rpdLimit: m.rpdLimit, monthlyTokenBudget: m.monthlyTokenBudget,
|
|
supportsVision: m.supportsVision, keyCount: 3,
|
|
}))
|
|
return send(rows)
|
|
}
|
|
|
|
if (url === '/api/fallback' && req.method === 'PUT') {
|
|
const body = await readBody(req)
|
|
for (const e of body as any[]) {
|
|
const m = models.find(x => x.modelDbId === e.modelDbId)
|
|
if (m) { m.priority = e.priority; m.enabled = e.enabled }
|
|
}
|
|
return send({ success: true })
|
|
}
|
|
|
|
if (url === '/api/fallback/routing' && req.method === 'GET') {
|
|
const scores = [...models]
|
|
.map(m => ({
|
|
modelDbId: m.modelDbId, platform: m.platform, modelId: m.modelId, displayName: m.displayName,
|
|
enabled: m.enabled, reliability: m.reliability, speed: m.speed, intelligence: m.intelligence,
|
|
headroom: m.headroom, rateLimit: m.rateLimit, score: score(m), totalRequests: m.totalRequests,
|
|
}))
|
|
.sort((a, b) => b.score - a.score)
|
|
return send({ strategy, weights: strategy === 'priority' ? null : activeWeights(), customWeights, scores })
|
|
}
|
|
|
|
if (url === '/api/fallback/routing' && req.method === 'PUT') {
|
|
const body = await readBody(req)
|
|
if (typeof body.strategy === 'string') strategy = body.strategy
|
|
if (body.weights && typeof body.weights === 'object') {
|
|
const { reliability = 0, speed = 0, intelligence = 0 } = body.weights
|
|
const sum = reliability + speed + intelligence
|
|
if (sum > 0) {
|
|
customWeights = { reliability: reliability / sum, speed: speed / sum, intelligence: intelligence / sum }
|
|
}
|
|
}
|
|
return send({ strategy, presets: PRESETS, customWeights })
|
|
}
|
|
|
|
if (url === '/api/fallback/token-usage') {
|
|
const withKeys = models
|
|
const modelBudgets = withKeys.map(m => ({ displayName: m.displayName, platform: m.platform, budget: budgetTokens(m.monthlyTokenBudget) }))
|
|
const totalBudget = modelBudgets.reduce((s, m) => s + m.budget, 0)
|
|
return send({ totalBudget, totalUsed: Math.round(totalBudget * 0.18), models: modelBudgets })
|
|
}
|
|
|
|
// Anything else under /api → empty 200 so pages don't hard-crash.
|
|
return send({})
|
|
})
|
|
},
|
|
}
|
|
}
|