Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions Documentation.md
Original file line number Diff line number Diff line change
@@ -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).

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand All @@ -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.

Expand Down Expand Up @@ -388,13 +425,15 @@ 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

| Version | Contenu |
|---|---|
| 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

Expand Down
30 changes: 30 additions & 0 deletions Modelfile.lite
Original file line number Diff line number Diff line change
@@ -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."""
13 changes: 13 additions & 0 deletions Modelfile.router
Original file line number Diff line number Diff line change
@@ -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"}."""
39 changes: 39 additions & 0 deletions brain/eval-router.mjs
Original file line number Diff line number Diff line change
@@ -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, ' ')}`);
106 changes: 106 additions & 0 deletions brain/flybrain.mjs
Original file line number Diff line number Diff line change
@@ -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; }
}
Loading
Loading