diff --git a/Documentation.md b/Documentation.md index 9c9e575..4daf5f6 100644 --- a/Documentation.md +++ b/Documentation.md @@ -1,4 +1,4 @@ -# Documentation de FlyCoder 0.2 beta +# Documentation de FlyCoder 0.3 beta FlyCoder est un modèle de code local qui tourne dans [Ollama](https://ollama.com), réglé pour les MacBook et Mac mini Apple Silicon. Cette documentation couvre l'installation, l'utilisation, les réglages, le banc d'essai, la publication et le dépannage. Pour une vue d'ensemble rapide, voir le [README](readme.md). @@ -204,8 +204,40 @@ ollama launch codex --model flycoder # Codex ollama launch opencode --model flycoder # OpenCode ``` +Si vous avez pris FlyCoder sur ollama.com sans `install.sh`, utilisez le nom publié : `ollama launch claude --model delairvictor9/flycoder`. + `ollama launch --help` liste les autres intégrations, dont VS Code, Copilot CLI, Cline, Qwen Code, Pi et Droid. Les agents envoient de longues consignes et beaucoup de fichiers : Ollama recommande au moins 64 000 tokens de contexte pour eux (section 6.2). +### 5.5 FlyBrain : le routeur qui baisse la RAM + +FlyBrain est un petit serveur Node 22, sans dépendance, qui se place devant Ollama sur le port 11435. Il répond aux noms `flycoder` et `flycoder:fast`, choisit un expert pour chaque demande et ne garde qu'un expert en mémoire. Les autres modèles et les autres routes passent tels quels. + +```sh +node ~/.flycoder/brain/flybrain.mjs # copié par install.sh ; ou npm run brain dans ce dépôt +node brain/flybrain.mjs --prefix delairvictor9/ # avec les modèles publiés sur ollama.com +``` + +Options : `--port` (11435), `--ollama` (http://127.0.0.1:11434), `--prefix`, `--max-expert auto|full|fast`. Avec `auto`, la valeur par défaut, un Mac de moins de 16 Go plafonne `flycoder` au 4B. + +Comment une demande est aiguillée : + +1. **Règles** (gratuites) : la demande est difficile si elle contient des outils (agents), plus de 6 000 caractères, plusieurs blocs de code, une spécification en liste, ou un mot comme débogue, algorithme, optimise, sécurité, erreur. Elle est simple si c'est une question courte sans code (« qu'est-ce que », « comment on », « explique »…). +2. **Micro-modèle** `flycoder:router` (Qwen3.5 0.8B, 1 Go) pour le reste. Il répond `{"level":"simple"}` ou `{"level":"hard"}` au format JSON imposé. S'il échoue, dépasse 8 s ou répond autre chose, la demande part vers le gros modèle. Il n'est pas appelé si le gros modèle est déjà chargé. `flycoder:fast` n'a pas de micro-modèle : une demande ambiguë garde le 4B. +3. **Mémoire de conversation** : une conversation garde son expert et ne peut que monter vers le plus fort. +4. **Un seul expert** : avant de charger un autre expert, FlyBrain attend la fin des réponses en cours, puis décharge l'ancien. Il décharge aussi le micro-modèle avant le gros modèle. + +La variante normale force la réflexion (`think: true`) sauf si le client la coupe. Chaque réponse porte les en-têtes `x-flybrain-expert` et `x-flybrain-reason`, et le terminal de FlyBrain affiche une ligne par décision. + +Brancher les outils : + +```sh +OLLAMA_HOST=127.0.0.1:11435 ollama run flycoder +ANTHROPIC_BASE_URL=http://127.0.0.1:11435 ANTHROPIC_AUTH_TOKEN=ollama ANTHROPIC_API_KEY="" claude --model flycoder +curl http://127.0.0.1:11435/v1/chat/completions -d '{"model":"flycoder","messages":[{"role":"user","content":"Bonjour"}]}' +``` + +Mesurer la justesse du routeur sur vos propres demandes : ajoutez-les à `tests/fixtures/route-prompts.json`, puis lancez `node brain/eval-router.mjs`. Les mesures de mémoire et de justesse sont dans le [README](readme.md#flybrain--moins-de-ram-même-qualité-sur-les-demandes-difficiles). + ## 6. Réglages ### 6.1 Ce que fixent les Modelfiles @@ -337,6 +369,9 @@ Pour une nouvelle version, mettez à jour la version dans `package.json`, `insta | Chemin | Rôle | |---|---| | `Modelfile`, `Modelfile.fast` | définitions des deux variantes | +| `Modelfile.lite`, `Modelfile.router` | expert 2B et micro-modèle de FlyBrain | +| `brain/flybrain.mjs`, `brain/router.mjs` | serveur FlyBrain et règles de routage | +| `brain/eval-router.mjs`, `tests/fixtures/route-prompts.json` | mesure de la justesse du routeur | | `install.sh` | installateur en une commande | | `scripts/publish.sh` | publication sur ollama.com | | `bench/bench.mjs`, `bench/problems.mjs` | banc d'essai et exercices | @@ -359,7 +394,9 @@ Les tests vérifient : - l'extraction du code des réponses et le calcul des statistiques ; - la lecture des réponses en flux continu et le délai limite d'exécution ; - que les deux Modelfiles partent des bonnes bases, avec les réglages attendus et une consigne identique hormis le nom de la base ; -- que l'installateur et le script de publication concordent avec les Modelfiles et la version. +- que l'installateur et le script de publication concordent avec les Modelfiles et la version ; +- les règles de FlyBrain (les 20 exercices du banc vont au gros modèle), le repli vers le gros modèle quand le micro-modèle échoue, la mémoire de conversation et l'ordonnanceur ; +- le serveur FlyBrain contre un faux Ollama : réécriture du modèle sur les API native, OpenAI et Anthropic, flux transmis, un seul expert chargé à la fois. La CI les lance sur Linux et sur macOS, où les solutions de référence tournent dans la sandbox. @@ -388,6 +425,7 @@ Ajoutez un objet à `bench/problems.mjs` avec : - La vitesse sur Mac n'a pas été mesurée pour cette version. Les gains MLX (environ +20 %) et multi-tokens (environ +90 % sur Apple Silicon) sont ceux annoncés par Ollama ; sur processeur, le gain multi-tokens mesuré est de 49 %. - Le mode réflexion n'a pas été mesuré par le banc. - Les tags MLX ne fonctionnent que sur Apple Silicon. +- FlyBrain ne baisse pas le pic de mémoire des demandes difficiles, et changer d'expert coûte quelques secondes. Ses mesures (mémoire, justesse sur 60 demandes) viennent d'un serveur Linux en GGUF, pas d'un Mac en MLX. Les règles ont été retouchées une fois après une première mesure sur ces mêmes demandes : le score sur des demandes nouvelles peut être un peu plus bas. ## 12. Historique des versions @@ -395,6 +433,7 @@ Ajoutez un objet à `bench/problems.mjs` avec : |---|---| | 0.1 beta | Atelier complet : Qwen3.5 4B, contrôleur FlyBrain entraînable, CLI, interface web et Electron, agents architecte, codeur et relecteur. | | 0.2 beta | FlyCoder devient uniquement un modèle Ollama : variante Gemma 4 12B (MLX, multi-tokens) et variante Qwen3.5 4B corrigée, installation en une commande, publication ollama.com, banc d'essai à tests cachés, atelier retiré. | +| 0.3 beta | Routeur FlyBrain facultatif : règles et micro-modèle Qwen3.5 0.8B, expert 2B pour `flycoder:fast`, un seul expert en mémoire. Moins de RAM pour les demandes simples. Les modèles 0.2 sont inchangés. | ## 13. Licences diff --git a/Modelfile.lite b/Modelfile.lite new file mode 100644 index 0000000..52f0e87 --- /dev/null +++ b/Modelfile.lite @@ -0,0 +1,30 @@ +# FlyCoder 0.2 beta lite: the small expert FlyBrain uses for simple requests in flycoder:fast. +# Base: Qwen3.5 2B (Apache 2.0), NVFP4 weights on Ollama's MLX engine for Apple Silicon. +# Build: ollama create flycoder:0.2-beta-lite -f Modelfile.lite +FROM qwen3.5:2b-nvfp4 + +# Simple requests are short: 8K tokens keeps the cache small next to 2.5 GB of weights. +PARAMETER num_ctx 8192 + +# Qwen's recommended sampling for precise coding (thinking mode). FlyCoder 0.1 used +# temperature 0.2, which Qwen warns can cause repetition and lower quality. +PARAMETER temperature 0.6 +PARAMETER top_k 20 +PARAMETER top_p 0.95 +PARAMETER min_p 0 +PARAMETER presence_penalty 0 +PARAMETER repeat_penalty 1 + +SYSTEM """You are FlyCoder 0.2 beta, a coding model running locally on the user's computer through Ollama. You are built on Qwen3.5 2B by the Qwen team at Alibaba, configured by the FlyCoder project. + +Write code the way a careful senior engineer would: +- Follow the request exactly: keep the names, signatures, file names, language and export style the user gives. +- Deliver complete, runnable code with every import it needs. No placeholders, no "TODO", no "rest of the code here". +- Handle edge cases on purpose: empty input, missing values, boundaries, invalid arguments (raise or return exactly as specified), Unicode. +- Prefer the standard library and the existing style of the project. Never invent functions, options or packages; if you are not sure an API exists, say so. +- Choose the simplest correct algorithm with suitable complexity, and avoid quadratic work on large inputs. +- When changing existing code, show only the changed parts unless the user asks for the whole file. +- Never claim that you ran code or tests. When useful, give a short command or test the user can run. +- Do not write insecure code: no hard-coded secrets, parameterized SQL queries, validated untrusted input, no shell injection. + +Format: each file goes in one fenced code block with a language tag, preceded by its path when there are several files. Keep explanations short and write them in the user's language.""" diff --git a/Modelfile.router b/Modelfile.router new file mode 100644 index 0000000..7dd66a2 --- /dev/null +++ b/Modelfile.router @@ -0,0 +1,13 @@ +# FlyCoder router: the micro-model FlyBrain asks when a request has no clear cue. +# It only answers {"level":"simple"} or {"level":"hard"}; FlyBrain forces that JSON format. +# Base: Qwen3.5 0.8B (Apache 2.0), NVFP4 on MLX. Build: ollama create flycoder:router -f Modelfile.router +FROM qwen3.5:0.8b-nvfp4 + +# The router reads at most 2,000 characters of the request. +PARAMETER num_ctx 2048 +PARAMETER temperature 0 + +SYSTEM """You route coding requests for FlyCoder. Read the user's request and classify it. +"simple": a short factual question, a syntax reminder, an explanation of a concept, a one-line fix, a tiny standard function. +"hard": anything that needs real reasoning: a bug to find, an algorithm, several functions or files, edge cases, performance, security, a design decision, or a precise specification. +When unsure, answer "hard". Reply only with JSON: {"level": "simple"} or {"level": "hard"}.""" diff --git a/brain/eval-router.mjs b/brain/eval-router.mjs new file mode 100644 index 0000000..84a75f2 --- /dev/null +++ b/brain/eval-router.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +// Measures how well FlyBrain routes labelled prompts: rules alone, then rules + micro-model. +// Usage: node brain/eval-router.mjs [--ollama http://127.0.0.1:11434] [--router flycoder:router] +import fs from 'node:fs'; +import { parseArgs } from 'node:util'; +import { digest, ruleRoute } from './router.mjs'; +import { PROBLEMS } from '../bench/problems.mjs'; +import { instruction } from '../bench/bench.mjs'; + +const { values } = parseArgs({ options: { ollama: { type: 'string', default: 'http://127.0.0.1:11434' }, router: { type: 'string', default: 'flycoder:router' } } }); +const labelled = JSON.parse(fs.readFileSync(new URL('../tests/fixtures/route-prompts.json', import.meta.url), 'utf8')); +const cases = [...labelled.simple.map(text => ({ text, want: 'simple' })), ...labelled.hard.map(text => ({ text, want: 'hard' })), + ...PROBLEMS.map(p => ({ text: instruction(p), want: 'hard' }))]; + +async function ask(text) { + const started = Date.now(); + const response = await fetch(values.ollama.replace(/\/$/, '') + '/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: values.router, stream: false, think: false, messages: [{ role: 'user', content: text.slice(0, 2000) }], + format: { type: 'object', properties: { level: { type: 'string', enum: ['simple', 'hard'] } }, required: ['level'] }, options: { temperature: 0, num_predict: 16 } }) }); + const data = await response.json(); + return { level: JSON.parse(data.message?.content || '{}').level, ms: Date.now() - started }; +} + +const rows = []; +for (const c of cases) { + const rule = ruleRoute(digest({ messages: [{ role: 'user', content: c.text }] })); + const model = rule.level === 'unsure' ? await ask(c.text).catch(e => ({ level: 'hard', error: e.message })) : null; + rows.push({ ...c, rule: rule.level, final: model ? (model.level === 'simple' ? 'simple' : 'hard') : rule.level, modelMs: model?.ms }); +} +const count = f => rows.filter(f).length; +const decided = rows.filter(r => r.rule !== 'unsure'); +console.log(`Cas : ${rows.length} (${count(r => r.want === 'simple')} simples, ${count(r => r.want === 'hard')} difficiles)`); +console.log(`Règles seules : ${decided.length} décidés, ${count(r => r.rule !== 'unsure' && r.rule === r.want)} justes, ${count(r => r.rule === 'unsure')} confiés au micro-modèle`); +console.log(`Règles + micro-modèle : ${count(r => r.final === r.want)}/${rows.length} justes`); +console.log(` difficile envoyé au petit expert (perte de qualité) : ${count(r => r.want === 'hard' && r.final === 'simple')}`); +console.log(` simple envoyé au gros expert (temps et RAM en plus) : ${count(r => r.want === 'simple' && r.final === 'hard')}`); +const times = rows.filter(r => r.modelMs).map(r => r.modelMs).sort((a, b) => a - b); +if (times.length) console.log(`Micro-modèle : médiane ${times[Math.floor(times.length / 2)]} ms sur ${times.length} appels`); +for (const r of rows.filter(r => r.final !== r.want)) console.log(` ✗ voulu ${r.want}, obtenu ${r.final} (${r.rule}) : ${r.text.slice(0, 90).replace(/\n/g, ' ')}`); diff --git a/brain/flybrain.mjs b/brain/flybrain.mjs new file mode 100644 index 0000000..e71fced --- /dev/null +++ b/brain/flybrain.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +// FlyBrain: an Ollama-compatible server that answers to `flycoder` and `flycoder:fast`, +// routes each request to one expert model and keeps a single expert in memory. +// Usage: node brain/flybrain.mjs [--port 11435] [--ollama http://127.0.0.1:11434] [--prefix delairvictor9/] [--max-expert auto|full|fast] +import http from 'node:http'; +import os from 'node:os'; +import fs from 'node:fs'; +import { parseArgs } from 'node:util'; +import { pathToFileURL } from 'node:url'; +import { Memory, profiles, route } from './router.mjs'; + +const ROUTED = new Set(['/api/chat', '/api/generate', '/v1/chat/completions', '/v1/messages', '/v1/completions']); + +// One expert at a time: a request for another expert waits for the running ones, +// then the previous expert (and the micro-model, before a large expert) is unloaded. +export class Scheduler { + constructor(unload) { this.unload = unload; this.current = null; this.active = 0; this.queue = []; this.switching = null; } + async acquire(expert, evict = []) { + while (this.switching || ((this.current !== expert || this.queue.length) && this.active > 0)) await (this.switching || new Promise(resolve => this.queue.push(resolve))); + if (this.current !== expert) { + const stale = [this.current, ...evict].filter(m => m && m !== expert); + this.switching = Promise.all(stale.map(m => this.unload(m).catch(() => {}))).then(() => { this.current = expert; this.switching = null; }); + await this.switching; + } + this.active++; + let released = false; + return () => { if (released) return; released = true; if (--this.active === 0) this.queue.splice(0).forEach(resolve => resolve()); }; + } +} + +export function createFlyBrain({ ollama = 'http://127.0.0.1:11434', prefix = '', maxExpert = 'full', log = console.error, routerTimeoutMs = 8000 } = {}) { + const upstream = ollama.replace(/\/$/, ''); + const table = profiles(prefix, { maxExpert }); + const memory = new Memory(); + const post = (path, body, signal) => fetch(upstream + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal }); + const scheduler = new Scheduler(model => post('/api/generate', { model, keep_alive: 0 }).then(r => r.arrayBuffer())); + const routers = new Set(Object.values(table).map(p => p.router).filter(Boolean)); + + async function askRouter(model, text) { + const response = await post('/api/chat', { model, stream: false, think: false, keep_alive: '10m', messages: [{ role: 'user', content: text }], + format: { type: 'object', properties: { level: { type: 'string', enum: ['simple', 'hard'] } }, required: ['level'] }, + options: { temperature: 0, num_predict: 16 } }, AbortSignal.timeout(routerTimeoutMs)); + if (!response.ok) throw new Error(`router ${response.status}`); + return JSON.parse((await response.json()).message?.content || '{}').level; + } + + async function forward(req, res, path, body, headers = {}) { + const raw = body === undefined ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body); + const abort = new AbortController(); + res.on('close', () => { if (!res.writableFinished) abort.abort(); }); + const response = await fetch(upstream + path, { method: req.method, signal: abort.signal, duplex: 'half', body: raw, + headers: { 'content-type': req.headers['content-type'] || 'application/json', ...(req.headers.authorization ? { authorization: req.headers.authorization } : {}) } }); + const out = { ...headers }; + for (const name of ['content-type', 'cache-control']) if (response.headers.get(name)) out[name] = response.headers.get(name); + res.writeHead(response.status, out); + if (response.body) for await (const chunk of response.body) res.write(chunk); + res.end(); + } + + async function handle(req, res) { + const path = new URL(req.url, 'http://flybrain').pathname; + const chunks = []; for await (const chunk of req) chunks.push(chunk); + const raw = Buffer.concat(chunks); + let body; try { body = raw.length ? JSON.parse(raw) : undefined; } catch {} + const profile = body && table[body.model]; + if (!profile || !(ROUTED.has(path) || path === '/api/show')) return forward(req, res, req.url, raw.length ? raw : undefined); + // Metadata (tools, thinking, context) comes from the strongest expert. + if (path === '/api/show') return forward(req, res, req.url, { ...body, model: profile.hard }); + + const decision = await route(body.model, body, { profile, memory, askRouter, loaded: scheduler.current }); + log(`[flybrain] ${body.model} → ${decision.expert} (${decision.level}: ${decision.reason})`); + const rewritten = { ...body, model: decision.expert }; + // The normal profile trades a little time for quality: it thinks unless the client said otherwise. + if (profile.think && rewritten.think === undefined && (path === '/api/chat' || path === '/api/generate')) rewritten.think = true; + const evict = decision.expert === profile.hard && profile.hard !== profile.simple ? [...routers] : []; + const release = await scheduler.acquire(decision.expert, evict); + res.on('close', release); + try { await forward(req, res, req.url, rewritten, { 'x-flybrain-expert': decision.expert, 'x-flybrain-reason': `${decision.level}: ${decision.reason}` }); } + finally { release(); } + } + + return http.createServer((req, res) => handle(req, res).catch(error => { + if (res.destroyed) return; // the client hung up: nothing to report + log(`[flybrain] ${error.message}`); + if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json' }); + res.end(res.headersSent ? undefined : JSON.stringify({ error: `FlyBrain: ${error.message}` })); + })); +} + +// Below 16 GB the large expert does not fit next to the system: cap at the 4B. +export function defaultMaxExpert(totalBytes = os.totalmem()) { return totalBytes / 2 ** 30 >= 15 ? 'full' : 'fast'; } + +function main() { + const { values } = parseArgs({ options: { port: { type: 'string', default: process.env.FLYBRAIN_PORT || '11435' }, host: { type: 'string', default: '127.0.0.1' }, + ollama: { type: 'string', default: process.env.OLLAMA_HOST ? `http://${process.env.OLLAMA_HOST.replace(/^https?:\/\//, '')}` : 'http://127.0.0.1:11434' }, + prefix: { type: 'string', default: '' }, 'max-expert': { type: 'string', default: 'auto' }, help: { type: 'boolean', short: 'h' } } }); + if (values.help) return console.log(fs.readFileSync(new URL(import.meta.url), 'utf8').split('\n').slice(1, 4).join('\n').replace(/^\/\/ ?/gm, '')); + const maxExpert = values['max-expert'] === 'auto' ? defaultMaxExpert() : values['max-expert']; + if (!['full', 'fast'].includes(maxExpert)) throw new Error('--max-expert must be auto, full or fast'); + const server = createFlyBrain({ ollama: values.ollama, prefix: values.prefix, maxExpert }); + server.listen(Number(values.port), values.host, () => console.error(`FlyBrain écoute sur http://${values.host}:${values.port} (Ollama : ${values.ollama}, expert max : ${maxExpert}). Modèles : flycoder, flycoder:fast`)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) { + try { main(); } catch (error) { console.error(`FlyBrain : ${error.message}`); process.exitCode = 1; } +} diff --git a/brain/router.mjs b/brain/router.mjs new file mode 100644 index 0000000..870c691 --- /dev/null +++ b/brain/router.mjs @@ -0,0 +1,85 @@ +// FlyBrain routing, modelled on the fly's mushroom body: cheap sparse cues +// (Kenyon cells) settle most requests for free; only the unclear ones go to a +// micro-model (the output neurons), and then a single expert "lobe" is loaded. + +const HARD_WORDS = /\b(debug\w*|d[ée]bog\w*|bug\w*|crash\w*|stack ?trace|traceback|segfault|fuite|leak\w*|refactor\w*|r[ée]usin\w*|architect\w*|algorithm\w*|algorithme\w*|complexit\w*|optimi[sz]\w*|perf\w*|concurren\w*|thread\w*|async\w*|deadlock|race condition|mutex|s[ée]curit\w*|secur\w*|vuln\w*|crypt\w*|injection|pars(e|er|eur|ing)|compil\w*|interpr[eé]t\w*|[ée]valuat\w*|dijkstra|graph[es]?|graphe\w*|dynamic programming|programmation dynamique|recurs\w*|r[ée]curs\w*|cache|lru|regex\w*|migration|schema|sch[ée]ma|prove|proof|prouve\w*|preuve|implement\w*|impl[ée]ment\w*|errors?|erreurs?|exceptions?|[ée]choue\w*|fail\w*|infinite loop|boucle infinie|en boucle|ne marche pas|not working|doesn.t work)\b/i; +const SIMPLE_WORDS = /^(what|what's|why|how do i|how to|when|which|is there|can i|explain|define|difference|qu'est-ce|qu’est-ce|c'est quoi|pourquoi|comment (on|faire|je)|quelle?s?|explique|d[ée]finis|diff[ée]rence|traduis|translate|rename|renomme)\b/i; + +// Plain text of a message in any of the shapes Ollama accepts +// (native, OpenAI chat completions, Anthropic messages). +function text(content) { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content.map(part => typeof part === 'string' ? part : part?.text ?? (part?.type === 'tool_result' ? text(part.content) : '')).join('\n'); +} + +export function digest(body = {}) { + const messages = Array.isArray(body.messages) ? body.messages : []; + const users = messages.filter(m => m?.role === 'user').map(m => text(m.content)); + if (typeof body.prompt === 'string') users.push(body.prompt); + const system = text(body.system) + messages.filter(m => m?.role === 'system').map(m => text(m.content)).join('\n'); + const last = users.at(-1) ?? ''; + return { first: users[0] ?? '', last, turns: users.length, hasTools: Array.isArray(body.tools) && body.tools.length > 0, + chars: system.length + messages.reduce((n, m) => n + text(m?.content).length, 0) + (body.prompt?.length ?? 0) }; +} + +// 'simple' | 'hard' | 'unsure', with the cue that decided it. +export function ruleRoute(d) { + const fences = (d.last.match(/```/g) || []).length / 2; + const specLines = (d.last.match(/^\s*([-*•]|\d+[.)])\s+/gm) || []).length; + if (d.hasTools) return { level: 'hard', reason: 'tools' }; + if (d.chars > 6000) return { level: 'hard', reason: 'long context' }; + if (fences >= 2) return { level: 'hard', reason: 'several code blocks' }; + if (specLines >= 3) return { level: 'hard', reason: 'detailed specification' }; + if (HARD_WORDS.test(d.last)) return { level: 'hard', reason: 'hard keyword' }; + if (d.last.length < 300 && fences === 0 && SIMPLE_WORDS.test(d.last.trim())) return { level: 'simple', reason: 'short question' }; + return { level: 'unsure', reason: 'no clear cue' }; +} + +// Experts behind each name FlyBrain answers to. `prefix` lets the published +// models be used directly, e.g. prefix 'delairvictor9/' for delairvictor9/flycoder:0.2-beta. +export function profiles(prefix = '', { maxExpert = 'full' } = {}) { + const tag = t => `${prefix}flycoder:${t}`; + const normal = { simple: tag('0.2-beta-fast'), hard: tag(maxExpert === 'fast' ? '0.2-beta-fast' : '0.2-beta'), router: tag('router'), think: true }; + // The fast profile has no micro-model: an unclear request keeps the 4B, so it never answers worse than before. + const fast = { simple: tag('0.2-beta-lite'), hard: tag('0.2-beta-fast'), router: null, think: false }; + return { flycoder: normal, 'flycoder:latest': normal, 'flycoder:fast': fast }; +} + +export class Memory { + constructor(limit = 500) { this.limit = limit; this.map = new Map(); } + key(model, d) { let h = 2166136261; for (const c of `${model}\0${d.first}`) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); } return h >>> 0; } + get(model, d) { return d.turns > 1 ? this.map.get(this.key(model, d)) : undefined; } + set(model, d, level) { + const key = this.key(model, d); + this.map.delete(key); this.map.set(key, level); + if (this.map.size > this.limit) this.map.delete(this.map.keys().next().value); + } +} + +const LEVELS = ['simple', 'hard']; +const higher = (a, b) => (LEVELS.indexOf(a) >= LEVELS.indexOf(b) ? a : b); + +// Picks the expert for one request. askRouter(model, text) resolves to 'simple' | 'hard' +// and may throw; any failure or unreadable answer goes to the hard expert. +export async function route(model, body, { profile, memory, askRouter, loaded } = {}) { + const d = digest(body); + const rule = ruleRoute(d); + const remembered = memory?.get(model, d); + let level = rule.level, reason = rule.reason; + if (level === 'unsure') { + if (remembered) [level, reason] = [remembered, 'same conversation']; + else if (loaded === profile.hard) [level, reason] = ['hard', 'hard expert already loaded']; + else if (!profile.router) [level, reason] = ['hard', 'unclear, keeping quality']; + else { + try { + const answer = await askRouter(profile.router, d.last.slice(0, 2000)); + [level, reason] = LEVELS.includes(answer) ? [answer, 'micro-model'] : ['hard', 'micro-model unreadable']; + } catch { [level, reason] = ['hard', 'micro-model failed']; } + } + } + // A conversation can move up to the stronger expert, never back down: switching costs a reload and a full re-read. + if (remembered && higher(remembered, level) !== level) [level, reason] = [remembered, 'same conversation']; + memory?.set(model, d, level); + return { level, reason, expert: profile[level] }; +} diff --git a/docs/ollama-model-page.md b/docs/ollama-model-page.md index 3c4680c..53ed2e4 100644 --- a/docs/ollama-model-page.md +++ b/docs/ollama-model-page.md @@ -13,7 +13,9 @@ ollama run delairvictor9/flycoder:fast # Qwen3.5 4B base, Macs with 8 GB |---|---|---|---| | `latest`, `0.2-beta` | Gemma 4 12B (Google DeepMind), NVFP4 on Ollama's MLX engine | 7.7 GB | 32,768 | | `fast`, `0.2-beta-fast` | Qwen3.5 4B (Alibaba Qwen), NVFP4 on Ollama's MLX engine | 4.0 GB | 16,384 | -| `0.2-beta-gguf`, `0.2-beta-fast-gguf` | Same bases as GGUF Q4_K_M, for Intel Macs, Linux and Windows | 8.0 / 3.4 GB | same | +| `0.2-beta-lite` | Qwen3.5 2B, NVFP4 on MLX: FlyBrain's expert for simple requests in `flycoder:fast` | 2.5 GB | 8,192 | +| `router` | Qwen3.5 0.8B, NVFP4 on MLX: FlyBrain's micro-router, answers only `simple` or `hard` | 1.0 GB | 2,048 | +| `*-gguf` | Same bases as GGUF Q4_K_M, for Intel Macs, Linux and Windows | 8.0 / 3.4 / 2.7 / 1.0 GB | same | MLX tags need Ollama 0.31 or later on Apple Silicon. @@ -25,6 +27,15 @@ MLX tags need Ollama 0.31 or later on Apple Silicon. - A real context window: Ollama otherwise defaults to 4,096 tokens on machines with less than 24 GB. - A short system prompt focused on complete, correct code: exact names and signatures, edge cases, no invented APIs, no claims of having run tests, secure defaults, answers in the user's language. +## Less memory with FlyBrain (optional) + +FlyBrain is a small Node server from the GitHub repository that sits in front of Ollama. It answers to `flycoder` and `flycoder:fast`, sends simple questions to a smaller expert, sends hard requests (and every agent request with tools) to the strong one, and keeps a single expert in memory. Measured on a 16 GB Linux server (GGUF, CPU): a simple request to `flycoder` uses 6.1 GiB instead of 10.5. Hard requests still use the strong model, so their quality is unchanged. + +```sh +node brain/flybrain.mjs --prefix delairvictor9/ +OLLAMA_HOST=127.0.0.1:11435 ollama run flycoder +``` + FlyCoder does not retrain weights. Weights keep their base license (Apache 2.0 for both bases). Source, installer and benchmark: https://github.com/Tromset/flycoder diff --git a/install.sh b/install.sh index 4ec1fdb..5c4dc4f 100755 --- a/install.sh +++ b/install.sh @@ -1,10 +1,11 @@ #!/bin/sh # FlyCoder 0.2 beta installer: builds the FlyCoder model inside your local Ollama. # curl -fsSL https://raw.githubusercontent.com/Tromset/flycoder/main/install.sh | sh -# sh install.sh [--fast | --all] [--gguf] -# --fast install only flycoder:0.2-beta-fast (Qwen3.5 4B, 8 GB Macs, maximum speed) -# --all install both variants -# --gguf use the portable GGUF weights instead of MLX (Intel Macs, Linux, Windows) +# sh install.sh [--fast | --all] [--gguf] [--no-brain] +# --fast install only flycoder:0.2-beta-fast (Qwen3.5 4B, 8 GB Macs, maximum speed) +# --all install both variants +# --gguf use the portable GGUF weights instead of MLX (Intel Macs, Linux, Windows) +# --no-brain skip the FlyBrain experts (flycoder:router, flycoder:0.2-beta-lite) set -eu VERSION=0.2-beta @@ -17,12 +18,14 @@ die() { printf 'FlyCoder : %s\n' "$*" >&2; exit 1; } choice=auto engine=auto +brain=yes for arg in "$@"; do case $arg in --fast) choice=fast ;; --all) choice=all ;; --gguf) engine=gguf ;; - -h|--help) sed -n '2,8p' "$0" 2>/dev/null || true; exit 0 ;; + --no-brain) brain=no ;; + -h|--help) sed -n '2,9p' "$0" 2>/dev/null || true; exit 0 ;; *) die "option inconnue : $arg" ;; esac done @@ -89,6 +92,25 @@ case $choice in ollama cp "flycoder:$VERSION" flycoder:latest ;; esac +# FlyBrain experts: the micro-router (1 GB) and the 2B expert for simple requests in flycoder:fast. +if [ "$brain" = yes ]; then + build Modelfile.router router qwen3.5:0.8b-nvfp4 qwen3.5:0.8b + build Modelfile.lite "$VERSION-lite" qwen3.5:2b-nvfp4 qwen3.5:2b + if [ "$choice" = default ]; then build Modelfile.fast "$VERSION-fast" qwen3.5:4b-mlx qwen3.5:4b; fi + # The FlyBrain server itself: two dependency-free Node files. + brain_dir=${FLYCODER_HOME:-$HOME/.flycoder}/brain + mkdir -p "$brain_dir" + for file in brain/router.mjs brain/flybrain.mjs; do + if [ -f "$here/$file" ]; then cp "$here/$file" "$brain_dir/" + else curl -fsSL "$RAW/$file" -o "$brain_dir/${file#brain/}" || die "téléchargement impossible : $RAW/$file"; fi + done +fi + say "" "FlyCoder est installé. Lancez : ollama run flycoder" +if [ "$brain" = yes ]; then + say "Avec le routeur FlyBrain (moins de RAM, Node 22) : node $brain_dir/flybrain.mjs" \ + "puis, dans un autre terminal : OLLAMA_HOST=127.0.0.1:11435 ollama run flycoder" + if [ "$choice" = fast ]; then say "(sans le modèle 12B, lancez FlyBrain avec --max-expert fast)"; fi +fi [ "$choice" = default ] && say "Version la plus rapide (8 Go) : sh install.sh --fast" exit 0 diff --git a/package.json b/package.json index 7a8497d..1ab73c0 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "flycoder", - "version": "0.2.0-beta.1", + "version": "0.3.0-beta.1", "private": true, "description": "FlyCoder: a local coding model for Ollama, tuned for MacBook and Mac mini", "scripts": { - "test": "node --test tests/bench.test.mjs tests/modelfile.test.mjs tests/fly-language.test.js", + "test": "node --test tests/bench.test.mjs tests/modelfile.test.mjs tests/router.test.mjs tests/fly-language.test.js", "bench": "node bench/bench.mjs", + "brain": "node brain/flybrain.mjs", "language:data": "node scripts/build_language_dataset.js", "language:train": ".venv/bin/python training/run.py --detach --serve", "language:serve": "HF_HOME=./data/huggingface .venv/bin/python -m mlx_lm server --model mlx-community/Qwen2.5-1.5B-Instruct-4bit --adapter-path data/fly-language/best --host 127.0.0.1 --port 8081", diff --git a/readme.md b/readme.md index 0e25d8e..ddd6079 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ -# FlyCoder 0.2 beta +# FlyCoder 0.3 beta -FlyCoder est un modèle de code local pour Ollama, réglé pour les MacBook et Mac mini Apple Silicon. Depuis la 0.2, FlyCoder est uniquement le modèle : l'ancien atelier (CLI, interface web, Electron, contrôleur FlyBrain) a été retiré. Vous l'utilisez directement avec `ollama run`, ou dans n'importe quel outil compatible Ollama. +FlyCoder est un modèle de code local pour Ollama, réglé pour les MacBook et Mac mini Apple Silicon. Vous l'utilisez directement avec `ollama run`, ou dans n'importe quel outil compatible Ollama. Depuis la 0.3, le routeur facultatif **FlyBrain** n'active que la partie de FlyCoder dont une demande a besoin, ce qui baisse la mémoire utilisée (voir plus bas). Documentation complète (installation, utilisation, API, réglages, banc d'essai, publication, dépannage) : [Documentation.md](Documentation.md). @@ -31,6 +31,40 @@ L'installateur détecte la mémoire du Mac et construit la bonne variante dans v Les deux bases sont sous licence Apache 2.0 et gèrent les outils (tool calling), la réflexion (thinking) et les images. +## FlyBrain : moins de RAM, même qualité sur les demandes difficiles + +FlyBrain s'inspire du cerveau de la mouche : de petits indices tirés de la demande (comme les cellules de Kenyon du corps pédonculé) suffisent à trancher les cas nets. Quand rien n'est net, un micro-modèle de 0,8 milliard de paramètres décide. Un seul « lobe » expert est ensuite chargé en mémoire. + +| Vous appelez | Demande simple | Demande difficile, outils (agents) ou long contexte | Demande ambiguë | +|---|---|---|---| +| `flycoder` | Qwen3.5 4B, avec réflexion | Gemma 4 12B, avec réflexion | le micro-modèle `flycoder:router` (Qwen3.5 0.8B) choisit | +| `flycoder:fast` | `flycoder:0.2-beta-lite` (Qwen3.5 2B) | Qwen3.5 4B | Qwen3.5 4B (pas de micro-modèle) | + +Mémoire mesurée (mémoire résidente des processus du modèle après une réponse, serveur Linux 16 Go sans GPU, poids GGUF ; sur Mac avec MLX, les valeurs absolues diffèrent) : + +| Cas | Sans FlyBrain | Avec FlyBrain | +|---|---|---| +| `flycoder`, demande simple | 10,5 Gio | **6,1 Gio** (4B + micro-modèle) | +| `flycoder`, demande difficile | 10,5 Gio | 10,5 Gio (identique : la qualité est gardée) | +| `flycoder:fast`, demande simple | 4,6 Gio | **3,7 Gio** | +| `flycoder:fast`, demande difficile | 4,6 Gio | 4,6 Gio | + +Le routeur se trompe rarement : sur 60 demandes étiquetées (les 20 exercices du banc et 40 demandes écrites pour l'occasion dans [tests/fixtures/route-prompts.json](tests/fixtures/route-prompts.json)), il en aiguille 59 correctement. Aucune demande difficile n'est partie vers le petit modèle ; une question git simple est partie vers le gros. Les règles décident seules 39 fois ; le micro-modèle répond en 0,6 s environ (médiane, sur processeur). Si le micro-modèle échoue ou hésite, la demande part vers le gros modèle. Une conversation garde son expert, et ne peut que monter vers le plus fort. Sous 16 Go de mémoire, FlyBrain plafonne `flycoder` au 4B. + +```sh +node ~/.flycoder/brain/flybrain.mjs # installé par install.sh ; ou : npm run brain depuis ce dépôt +OLLAMA_HOST=127.0.0.1:11435 ollama run flycoder +``` + +FlyBrain parle l'API d'Ollama sur le port 11435 et laisse passer tous les autres modèles. Pour un agent de code, pointez-le sur FlyBrain plutôt que sur Ollama : + +```sh +ANTHROPIC_BASE_URL=http://127.0.0.1:11435 ANTHROPIC_AUTH_TOKEN=ollama ANTHROPIC_API_KEY="" claude --model flycoder # Claude Code +# Codex, OpenCode, Cline… : URL compatible OpenAI http://127.0.0.1:11435/v1, modèle flycoder +``` + +Avec les modèles publiés sur ollama.com plutôt qu'installés par `install.sh` : `ollama pull delairvictor9/flycoder:router` (et `:0.2-beta-lite`, `:0.2-beta-fast`, `:0.2-beta`), puis `node brain/flybrain.mjs --prefix delairvictor9/`. + ## Ce qui change par rapport à la 0.1 **Un modèle de base nettement plus fort.** La 0.1 reposait sur Qwen3.5 4B. Sur LiveCodeBench v6, chiffres publiés par les auteurs en mode réflexion, Gemma 4 12B obtient 72,0 % contre 55,8 % pour Qwen3.5 4B (et 65,6 % pour Qwen3.5 9B, qui serait deux fois plus lent). Son Elo Codeforces publié est de 1659. @@ -82,7 +116,7 @@ ollama run flycoder --think=false # réponse immédiate, pour les questio ollama run flycoder:fast # variante 4B si vous l'avez installée ``` -Dans une conversation, `/set nothink` coupe la réflexion et `/set parameter num_ctx 65536` agrandit le contexte (environ 0,5 Go de mémoire en plus pour la variante principale). Ollama recommande au moins 64 000 tokens pour les agents de code ; FlyCoder fonctionne aussi avec `ollama launch` (Claude Code, Codex, OpenCode) : `ollama launch claude --model flycoder`. +Dans une conversation, `/set nothink` coupe la réflexion et `/set parameter num_ctx 65536` agrandit le contexte (environ 0,5 Go de mémoire en plus pour la variante principale). Ollama recommande au moins 64 000 tokens pour les agents de code ; FlyCoder fonctionne aussi avec `ollama launch` (Claude Code, Codex, OpenCode) : `ollama launch claude --model flycoder` si vous l'avez installé avec `install.sh`, ou `ollama launch claude --model delairvictor9/flycoder` depuis ollama.com. `ollama launch` parle directement à Ollama ; pour passer par FlyBrain, voir la section FlyBrain. Depuis une application, l'API d'Ollama suffit : @@ -105,6 +139,8 @@ Ensuite, n'importe qui peut lancer `ollama run /flycoder` ou `ollam ## Contenu du dépôt - `Modelfile`, `Modelfile.fast` : définitions des deux variantes (`ollama create flycoder -f Modelfile`). +- `Modelfile.lite`, `Modelfile.router` : expert 2B et micro-modèle de FlyBrain. +- `brain/` : le serveur FlyBrain (`flybrain.mjs`), ses règles (`router.mjs`) et la mesure de justesse du routeur (`eval-router.mjs`). - `install.sh` : installation en une commande. `scripts/publish.sh` : publication sur ollama.com. - `bench/` : banc d'essai qualité et vitesse ; `bench/baselines/` garde le profil 0.1 pour comparer. - `tests/` : `npm test` vérifie les tests du banc, la cohérence des Modelfiles et des scripts. @@ -118,6 +154,7 @@ Ensuite, n'importe qui peut lancer `ollama run /flycoder` ou `ollam - Sans réflexion, les trois profils laissent parfois une première tentative abandonnée dans le code rendu (3 réponses sur 20 chacun) : gardez la réflexion activée pour du code à livrer. - Le banc a tourné sans réflexion. Avec la réflexion activée (le défaut de `ollama run`), la qualité monte pour les deux bases mais les réponses sont plus longues ; mesurez-le avec `npm run bench -- --think on`. - Les variantes MLX demandent un Mac Apple Silicon ; ailleurs, utilisez `--gguf`. +- FlyBrain baisse la mémoire des demandes simples, pas celle des demandes difficiles : le pic reste celui du gros modèle. Changer d'expert prend quelques secondes de rechargement. Les agents (qui envoient des outils) vont toujours au gros modèle. Les mesures de mémoire et du routeur viennent d'un serveur Linux en GGUF, pas encore d'un Mac en MLX. ## Licence diff --git a/scripts/publish.sh b/scripts/publish.sh index 3e415cc..b7f54d8 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -3,7 +3,7 @@ # sh scripts/publish.sh [--gguf] # Prerequisites: an ollama.com account, then `ollama signin` once on this Mac. # The models must exist locally: run `sh install.sh --all` first. -# --gguf also publish portable GGUF tags (0.2-beta-gguf, 0.2-beta-fast-gguf) for Intel Macs, Linux and Windows +# --gguf also publish portable GGUF tags (0.2-beta-gguf, 0.2-beta-fast-gguf, 0.2-beta-lite-gguf, router-gguf) for Intel Macs, Linux and Windows set -eu VERSION=0.2-beta @@ -25,8 +25,10 @@ push() { # push here=$(cd "$(dirname "$0")/.." && pwd) require "flycoder:$VERSION" require "flycoder:$VERSION-fast" +require "flycoder:$VERSION-lite" +require flycoder:router # The main tags must hold the Apple Silicon (MLX) builds, never a GGUF fallback. -for model in "flycoder:$VERSION" "flycoder:$VERSION-fast"; do +for model in "flycoder:$VERSION" "flycoder:$VERSION-fast" "flycoder:$VERSION-lite" flycoder:router; do [ "$(format "$model")" = nvfp4 ] || die "$model n'est pas la version MLX. Publiez depuis un Mac Apple Silicon après : sh install.sh --all" done @@ -34,15 +36,24 @@ push "flycoder:$VERSION" "$VERSION" push "flycoder:$VERSION" latest push "flycoder:$VERSION-fast" "$VERSION-fast" push "flycoder:$VERSION-fast" fast +# FlyBrain experts, used by: node brain/flybrain.mjs --prefix / +push "flycoder:$VERSION-lite" "$VERSION-lite" +push flycoder:router router if [ "$gguf" = --gguf ]; then tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT sed 's|^FROM gemma4:12b-mlx$|FROM gemma4:12b|' "$here/Modelfile" > "$tmp/Modelfile" sed 's|^FROM qwen3.5:4b-mlx$|FROM qwen3.5:4b|' "$here/Modelfile.fast" > "$tmp/Modelfile.fast" + sed 's|^FROM qwen3.5:2b-nvfp4$|FROM qwen3.5:2b|' "$here/Modelfile.lite" > "$tmp/Modelfile.lite" + sed 's|^FROM qwen3.5:0.8b-nvfp4$|FROM qwen3.5:0.8b|' "$here/Modelfile.router" > "$tmp/Modelfile.router" ollama create "flycoder:$VERSION-gguf" -f "$tmp/Modelfile" ollama create "flycoder:$VERSION-fast-gguf" -f "$tmp/Modelfile.fast" + ollama create "flycoder:$VERSION-lite-gguf" -f "$tmp/Modelfile.lite" + ollama create flycoder:router-gguf -f "$tmp/Modelfile.router" push "flycoder:$VERSION-gguf" "$VERSION-gguf" push "flycoder:$VERSION-fast-gguf" "$VERSION-fast-gguf" + push "flycoder:$VERSION-lite-gguf" "$VERSION-lite-gguf" + push flycoder:router-gguf router-gguf fi printf '\nPublié. Tout le monde peut maintenant lancer :\n ollama run %s/flycoder (Gemma 4 12B, Mac 16 Go et plus)\n ollama run %s/flycoder:fast (Qwen3.5 4B, Mac 8 Go)\n' "$user" "$user" diff --git a/tests/fixtures/route-prompts.json b/tests/fixtures/route-prompts.json new file mode 100644 index 0000000..fe7fc2e --- /dev/null +++ b/tests/fixtures/route-prompts.json @@ -0,0 +1,46 @@ +{ + "simple": [ + "Qu'est-ce qu'une closure en JavaScript ?", + "How do I reverse a list in Python?", + "Explique la différence entre let et const.", + "Comment on fait une boucle for en Go ?", + "What is the difference between == and === in JavaScript?", + "Pourquoi utiliser un environnement virtuel Python ?", + "Écris une fonction qui met une chaîne en majuscules.", + "Write a Python function that returns the sum of a list.", + "Comment convertir une chaîne en entier en Java ?", + "Donne-moi la commande git pour annuler le dernier commit sans perdre les fichiers.", + "What does the yield keyword do in Python?", + "Fais une fonction JavaScript qui renvoie le maximum de deux nombres.", + "Quelle est la syntaxe d'une list comprehension ?", + "How do I read a file line by line in Node.js?", + "Renomme la variable x en total dans: x = a + b", + "Write a SQL query that selects all users older than 30.", + "Comment centrer une div en CSS ?", + "Écris un hello world en Rust.", + "What is a pure function?", + "Ajoute un commentaire de documentation à: def add(a, b): return a + b" + ], + "hard": [ + "Write a function that returns the k most frequent words in a text, ties broken alphabetically, in O(n log k).", + "Écris un serveur HTTP en Node sans dépendance qui limite chaque IP à 100 requêtes par minute.", + "Mon test passe en local mais échoue en CI avec un timeout aléatoire, voici le code: ```js\nawait Promise.all(jobs.map(run))\n```", + "Implement a trie with insert, search and prefix counting, plus deletion that frees empty nodes.", + "Transforme ce module callback en async/await sans changer son API publique, et garde la gestion d'erreurs identique.", + "Write a function that checks whether a Sudoku grid is solvable and returns one solution.", + "Écris une fonction qui fusionne deux listes triées de dates ISO avec fuseaux horaires différents.", + "Design the database tables for a multi-tenant invoicing app with soft deletes and audit history.", + "Pourquoi mon useEffect React se déclenche en boucle infinie quand je mets un objet dans les dépendances ?", + "Write a tokenizer and a recursive descent evaluator for arithmetic with + - * / and parentheses.", + "Écris un script Python qui synchronise deux dossiers en ne copiant que les fichiers modifiés, avec reprise après coupure.", + "Find why this returns wrong results for negative numbers: def mod(a, b): return a - b * int(a / b)", + "Make this Python loop over 10 million rows faster without pandas.", + "Write a thread-safe bounded queue in Java with blocking put and take.", + "Implémente l'authentification par jeton JWT avec rotation des refresh tokens dans Express.", + "Write a function to compute the edit distance between two strings and return the list of edit operations.", + "Écris un débounce et un throttle en TypeScript avec annulation et typage générique correct.", + "Convert this recursive Fibonacci to an iterative version and add memoization for big inputs, explaining the complexity.", + "Écris une fonction qui valide un IBAN pour tous les pays européens.", + "Write a CSV parser that handles quoted fields, escaped quotes and newlines inside quotes." + ] +} diff --git a/tests/modelfile.test.mjs b/tests/modelfile.test.mjs index 9384d3e..9c1e047 100644 --- a/tests/modelfile.test.mjs +++ b/tests/modelfile.test.mjs @@ -17,8 +17,11 @@ function parse(text) { const VARIANTS = [ { file: 'Modelfile', tag: '0.2-beta', mlx: 'gemma4:12b-mlx', gguf: 'gemma4:12b', base: 'Gemma 4 12B by Google DeepMind', params: { num_ctx: '32768', temperature: '1', top_k: '64', top_p: '0.95' } }, { file: 'Modelfile.fast', tag: '0.2-beta-fast', mlx: 'qwen3.5:4b-mlx', gguf: 'qwen3.5:4b', base: 'Qwen3.5 4B by the Qwen team at Alibaba', - params: { num_ctx: '16384', temperature: '0.6', top_k: '20', top_p: '0.95', min_p: '0', presence_penalty: '0', repeat_penalty: '1' } } + params: { num_ctx: '16384', temperature: '0.6', top_k: '20', top_p: '0.95', min_p: '0', presence_penalty: '0', repeat_penalty: '1' } }, + { file: 'Modelfile.lite', tag: '0.2-beta-lite', mlx: 'qwen3.5:2b-nvfp4', gguf: 'qwen3.5:2b', base: 'Qwen3.5 2B by the Qwen team at Alibaba', + params: { num_ctx: '8192', temperature: '0.6', top_k: '20', top_p: '0.95', min_p: '0', presence_penalty: '0', repeat_penalty: '1' } } ]; +const ROUTER = { file: 'Modelfile.router', tag: 'router', mlx: 'qwen3.5:0.8b-nvfp4', gguf: 'qwen3.5:0.8b', params: { num_ctx: '2048', temperature: '0' } }; const KNOWN = new Set(['num_ctx', 'temperature', 'top_k', 'top_p', 'min_p', 'presence_penalty', 'repeat_penalty', 'repeat_last_n', 'seed', 'stop', 'num_predict', 'draft_num_predict']); for (const v of VARIANTS) { @@ -32,21 +35,32 @@ for (const v of VARIANTS) { }); } -test('both variants share one system prompt, apart from the base model sentence', () => { - const [a, b] = VARIANTS.map(v => parse(read(v.file)).system.replace(v.base, '')); - assert.equal(a, b); +test('every expert shares one system prompt, apart from the base model sentence', () => { + const [a, ...others] = VARIANTS.map(v => parse(read(v.file)).system.replace(v.base, '')); + for (const b of others) assert.equal(b, a); assert.match(a, /^You are FlyCoder 0\.2 beta/); assert.ok(a.length < 2500, 'keep the system prompt short: it is processed on every new conversation'); }); +test('Modelfile.router is a tiny deterministic classifier that answers simple or hard', () => { + const m = parse(read(ROUTER.file)); + assert.deepEqual(m.from, [ROUTER.mlx]); + assert.deepEqual(m.unknown, []); + assert.deepEqual(m.params, ROUTER.params); + assert.match(m.system, /"simple"/); assert.match(m.system, /"hard"/); + assert.match(m.system, /When unsure, answer "hard"/); +}); + test('installer and publisher agree with the Modelfiles and the package version', () => { const install = read('install.sh'), publish = read('scripts/publish.sh'), pkg = JSON.parse(read('package.json')); - assert.match(pkg.version, /^0\.2\.0-beta\./); + assert.match(pkg.version, /^0\.3\.0-beta\./); for (const script of [install, publish]) assert.match(script, /^VERSION=0\.2-beta$/m); - for (const v of VARIANTS) { - assert.ok(install.includes(`build ${v.file} "$VERSION${v.tag.slice('0.2-beta'.length)}" ${v.mlx} ${v.gguf}`), `install.sh builds ${v.file}`); + for (const v of [...VARIANTS, ROUTER]) { + const tag = v === ROUTER ? 'router' : `"$VERSION${v.tag.slice('0.2-beta'.length)}"`; + assert.ok(install.includes(`build ${v.file} ${tag} ${v.mlx} ${v.gguf}`), `install.sh builds ${v.file}`); assert.ok(publish.includes(`FROM ${v.mlx}$|FROM ${v.gguf}`), `publish.sh maps ${v.mlx} to ${v.gguf}`); } + for (const file of ['brain/router.mjs', 'brain/flybrain.mjs']) assert.ok(install.includes(file), `install.sh installs ${file}`); }); test('shell scripts parse with POSIX sh', () => { diff --git a/tests/router.test.mjs b/tests/router.test.mjs new file mode 100644 index 0000000..c743a7d --- /dev/null +++ b/tests/router.test.mjs @@ -0,0 +1,135 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { digest, ruleRoute, route, profiles, Memory } from '../brain/router.mjs'; +import { createFlyBrain, Scheduler, defaultMaxExpert } from '../brain/flybrain.mjs'; +import { PROBLEMS } from '../bench/problems.mjs'; +import { instruction } from '../bench/bench.mjs'; + +const user = content => ({ messages: [{ role: 'user', content }] }); +const level = body => ruleRoute(digest(body)).level; + +test('rules send every bench problem to the hard expert', () => { + for (const p of PROBLEMS) assert.equal(level(user(instruction(p))), 'hard', p.id); +}); + +test('rules: short questions are simple, agents and long or detailed requests are hard', () => { + for (const q of ["Qu'est-ce qu'une closure en JavaScript ?", 'How do I reverse a list in Python?', 'Explique la différence entre let et const.']) + assert.equal(level(user(q)), 'simple', q); + assert.equal(level({ ...user('salut'), tools: [{ type: 'function', function: { name: 'read' } }] }), 'hard'); + assert.equal(level(user('x'.repeat(7000))), 'hard'); + assert.equal(level(user('Pourquoi ce code plante ?\n```py\na()\n```\n```\nTraceback\n```')), 'hard'); + assert.equal(level(user('Débogue cette fonction')), 'hard'); + assert.equal(level(user('Écris une fonction qui met une chaîne en majuscules')), 'unsure'); +}); + +test('digest reads native, OpenAI and Anthropic shapes', () => { + assert.equal(digest({ prompt: 'hello' }).last, 'hello'); + assert.equal(digest({ messages: [{ role: 'user', content: [{ type: 'text', text: 'a' }, { type: 'image_url' }] }] }).last, 'a\n'); + const anthropic = digest({ system: [{ type: 'text', text: 'sys' }], messages: [{ role: 'user', content: 'first' }, { role: 'assistant', content: 'ok' }, { role: 'user', content: [{ type: 'tool_result', content: 'out' }] }] }); + assert.deepEqual([anthropic.first, anthropic.last, anthropic.turns], ['first', 'out', 2]); +}); + +test('route: the micro-model settles unclear requests, and any failure keeps quality', async () => { + const profile = profiles().flycoder, body = user('Écris une fonction qui met une chaîne en majuscules'); + assert.equal((await route('flycoder', body, { profile, askRouter: async () => 'simple' })).expert, 'flycoder:0.2-beta-fast'); + assert.equal((await route('flycoder', body, { profile, askRouter: async () => 'hard' })).expert, 'flycoder:0.2-beta'); + assert.equal((await route('flycoder', body, { profile, askRouter: async () => 'maybe' })).level, 'hard'); + assert.equal((await route('flycoder', body, { profile, askRouter: async () => { throw new Error('down'); } })).level, 'hard'); + // No micro-model call when the hard expert is already in memory. + const skipped = await route('flycoder', body, { profile, loaded: profile.hard, askRouter: () => assert.fail('router called') }); + assert.equal(skipped.reason, 'hard expert already loaded'); + // flycoder:fast has no micro-model: unclear requests keep the 4B. + assert.equal((await route('flycoder:fast', body, { profile: profiles()['flycoder:fast'] })).expert, 'flycoder:0.2-beta-fast'); +}); + +test('route: a conversation keeps its expert and can only move up', async () => { + const profile = profiles().flycoder, memory = new Memory(); + const first = [{ role: 'user', content: 'Explique les closures.' }]; + assert.equal((await route('flycoder', { messages: first }, { profile, memory })).level, 'simple'); + const turn2 = [...first, { role: 'assistant', content: '...' }, { role: 'user', content: 'Et avec un exemple ?' }]; + assert.equal((await route('flycoder', { messages: turn2 }, { profile, memory, askRouter: () => assert.fail('router called') })).level, 'simple'); + const turn3 = [...turn2, { role: 'assistant', content: '...' }, { role: 'user', content: 'Débogue ce code maintenant.' }]; + assert.equal((await route('flycoder', { messages: turn3 }, { profile, memory })).level, 'hard'); + const turn4 = [...turn3, { role: 'assistant', content: '...' }, { role: 'user', content: 'Merci, et pourquoi ?' }]; + assert.equal((await route('flycoder', { messages: turn4 }, { profile, memory })).level, 'hard'); +}); + +test('profiles: prefix for published models and the 8 GB cap', () => { + assert.equal(profiles('delairvictor9/').flycoder.hard, 'delairvictor9/flycoder:0.2-beta'); + assert.equal(profiles('', { maxExpert: 'fast' }).flycoder.hard, 'flycoder:0.2-beta-fast'); + assert.equal(defaultMaxExpert(8 * 2 ** 30), 'fast'); + assert.equal(defaultMaxExpert(16 * 2 ** 30), 'full'); +}); + +test('scheduler: a different expert waits for running requests, then the old one is unloaded', async () => { + const unloaded = [], order = []; + const s = new Scheduler(async m => { unloaded.push(m); }); + const tick = () => new Promise(r => setTimeout(r, 10)); + const releaseA = await s.acquire('a'); + const b = s.acquire('b').then(release => { order.push('b'); return release; }); + // Fairness: a later request for the loaded expert queues behind the waiting one. + const a2 = s.acquire('a').then(release => { order.push('a2'); return release; }); + await tick(); + assert.deepEqual(order, []); + releaseA(); await tick(); + assert.deepEqual(order, ['b']); + assert.equal(s.current, 'b'); + (await b)(); await tick(); + assert.deepEqual(order, ['b', 'a2']); + (await a2)(); + assert.deepEqual(unloaded, ['a', 'b']); +}); + +// A fake Ollama that records requests and the models it holds in memory. +async function fakeOllama() { + const seen = [], loaded = new Set(); + let maxLoaded = 0; + const server = http.createServer(async (req, res) => { + let raw = ''; for await (const c of req) raw += c; + const body = raw ? JSON.parse(raw) : {}; + seen.push({ path: req.url, ...body }); + if (body.keep_alive === 0) { loaded.delete(body.model); return res.end('{}'); } + if (req.url === '/api/tags') return res.end(JSON.stringify({ models: [] })); + if (req.url === '/api/show') return res.end(JSON.stringify({ model: body.model })); + loaded.add(body.model); maxLoaded = Math.max(maxLoaded, [...loaded].filter(m => !m.endsWith('router')).length); + if (body.model.endsWith('router')) return res.end(JSON.stringify({ message: { content: '{"level":"simple"}' }, done: true })); + res.writeHead(200, { 'content-type': 'application/x-ndjson' }); + res.write(JSON.stringify({ model: body.model, message: { content: 'hi' }, done: false }) + '\n'); + res.end(JSON.stringify({ model: body.model, done: true }) + '\n'); + }); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + return { url: `http://127.0.0.1:${server.address().port}`, seen, loaded, maxLoaded: () => maxLoaded, close: () => server.close() }; +} + +test('FlyBrain server rewrites the model, streams the answer and keeps one expert loaded', async () => { + const ollama = await fakeOllama(); + const brain = createFlyBrain({ ollama: ollama.url, log: () => {} }); + await new Promise(r => brain.listen(0, '127.0.0.1', r)); + const base = `http://127.0.0.1:${brain.address().port}`; + const post = (path, body) => fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + try { + const simple = await post('/api/chat', { model: 'flycoder', ...user('How do I reverse a list in Python?') }); + assert.equal(simple.headers.get('x-flybrain-expert'), 'flycoder:0.2-beta-fast'); + assert.match(await simple.text(), /"done":true/); + assert.equal(ollama.seen.at(-1).think, true, 'normal profile thinks by default'); + + const unclear = await post('/v1/chat/completions', { model: 'flycoder', messages: [{ role: 'user', content: 'Écris une fonction qui met une chaîne en majuscules' }] }); + assert.equal(unclear.headers.get('x-flybrain-reason'), 'simple: micro-model'); + await unclear.text(); + + const hard = await post('/v1/messages', { model: 'flycoder', max_tokens: 10, tools: [{ name: 'read', input_schema: {} }], messages: [{ role: 'user', content: 'hi' }] }); + assert.equal(hard.headers.get('x-flybrain-expert'), 'flycoder:0.2-beta'); + await hard.text(); + assert.deepEqual([...ollama.loaded], ['flycoder:0.2-beta'], 'small expert and micro-model unloaded before the 12B'); + assert.equal(ollama.maxLoaded(), 1); + + const show = await post('/api/show', { model: 'flycoder:fast' }); + assert.equal((await show.json()).model, 'flycoder:0.2-beta-fast'); + const other = await post('/api/chat', { model: 'llama3', ...user('hi') }); + assert.equal(other.headers.get('x-flybrain-expert'), null); + await other.text(); + assert.equal(ollama.seen.at(-1).model, 'llama3', 'other models pass through untouched'); + assert.equal((await fetch(base + '/api/tags')).status, 200); + } finally { brain.close(); ollama.close(); } +});