Fuuga.Server.cpu
1.0.41
dotnet tool install --global Fuuga.Server.cpu --version 1.0.41
dotnet new tool-manifest
dotnet tool install --local Fuuga.Server.cpu --version 1.0.41
#tool dotnet:?package=Fuuga.Server.cpu&version=1.0.41
nuke :add-package Fuuga.Server.cpu --version 1.0.41
Fuuga.Server
OpenAI and Anthropic compatible HTTP server for Fuuga -- the F# LLM framework.
Built with Oxpecker on ASP.NET Core / Kestrel.
Installation
Clone the repo and run locally with dotnet run. The NuGet package exceeds the 250 MB size limit for .NET tools due to native runtime dependencies (libtorch, CUDA), so dotnet tool install is not supported.
git clone https://bitbucket.org/Thorium/fuuga.git
cd fuuga/Fuuga.Server
dotnet run -- --checkpoint ../checkpoints/best --tokenizer ../data/tokenizer --port 8080
Prerequisites: .NET 10 SDK (v10.0.103+).
Usage
fuuga-serve --checkpoint <dir> --tokenizer <dir> [options]
fuuga-serve --package <path.fuuga> [options]
fuuga-serve --decision-model <dir> [--decide-* options]
Examples
# Serve from a checkpoint directory:
fuuga-serve --checkpoint checkpoints/step-5000 --tokenizer data/tokenizer --port 8080
# Serve from a .fuuga package:
fuuga-serve --package my-model.fuuga --port 8080
# With API key authentication and request tracing:
fuuga-serve --package my-model.fuuga --api-key secret123 --observe
# CPU offloading to reduce VRAM:
fuuga-serve --package my-model.fuuga --offload cpu --gpu-layers 12
# Typed decisions only (POST /v1/decide), e.g. an imported Laya; no generative model loaded:
fuuga-serve --decision-model models/laya-fuuga --decide-calibration models/laya-fuuga/calibration.json
Options
| Option | Description |
|---|---|
--checkpoint <dir> |
Checkpoint directory (step-N folder) |
--tokenizer <dir> |
Tokenizer directory |
--package <path> |
Load from a .fuuga package (replaces --checkpoint + --tokenizer) |
--host <addr> |
Listen address (default: 127.0.0.1) |
--port <N> |
Listen port (default: 8080) |
--api-key <key> |
Require Bearer token / x-api-key for /v1/* routes |
--model-name <name> |
Override model name (default: derived from checkpoint dir) |
--mcp-config <path> |
MCP server config file for tool calling |
--api-compat <list> |
API shapes, comma-separated: openai, anthropic, jev (typed decisions at POST /v1/systemone, the Jev API shape); both = openai,anthropic; all (default) |
--observe |
Enable request tracing via OpenTelemetry |
--offload cpu |
Offload layers to CPU (reduces VRAM usage) |
--gpu-layers <N> |
Keep first N layers on GPU (rest offloaded) |
--mmap |
Use memory-mapped loading from model.safetensors (faster startup) |
--rag <index.json> |
Retrieve context per request from a vector index (build with fuuga rag index) |
--rag-top-k <N> |
Chunks to retrieve per request (default: 4) |
--rag-threshold <S> |
Minimum cosine similarity to include a retrieved chunk (default: 0.0) |
--rag-hybrid |
Fuse dense and BM25 keyword rankings (reciprocal rank fusion) |
--rag-rerank <mode> |
Rerank the retrieved candidates before the top-k go into the prompt: mmr (diversity), cross (a cross-encoder judge, --reranker) or judge (the typed relevance question "the passage contains the information needed to answer this question", answered by --decision-model, else by the served model's option log-probs). A judge reads the query with each candidate and writes nothing. A failing judge keeps the retrieval order |
--reranker <dir> |
Cross-encoder for --rag-rerank cross (fuuga import-reranker) |
--rag-candidates <N> |
Candidates retrieved before reranking (default: 3 x --rag-top-k) |
--embedder <dir> |
Query embedder: where the embedder the --rag index records is now (an index made with fuuga rag index --embedder is queried through it automatically), or the semantic cache's embedder without --rag |
--embedder-cpu / --reranker-cpu / --decision-model-cpu |
Run that side model on the CPU instead of the GPU, leaving the GPU to the served model. Measured on a 6 GB laptop GPU vs its CPU: a query embedding 29 vs 231 ms, reranking 12 paragraphs 0.45 vs 2.4 s, Laya judging one passage 0.09 vs 0.49 s. With a generative model, an embedder, a reranker and Laya on one 6 GB GPU the memory runs out (5,995 of 6,144 MiB) and an answer takes 47-168 s; with --embedder-cpu --reranker-cpu the GPU peaks at 5.5 GB and answers take 16-20 s |
--guardrails |
Enable input/output guardrails (prompt-injection, PII masking, content filtering) |
--continuous-batching |
Serve concurrent requests through the continuous-batching engine (experimental; see Serving modes below) |
--cb-cache-gb <G> |
KV-cache pool budget (GiB) for --continuous-batching. Default: auto (a fraction of free VRAM on CUDA, system RAM on CPU). Env: FUUGA_CB_CACHE_GB |
--provider-config <path> |
Host as an OpenRouter provider (pricing + prepaid credit ledger). See Hosting as an OpenRouter provider. |
--drift-state <dir> |
Monitor output drift: confidence signals of every completed generation feed a reference-vs-current statistical test on a timer; retraining triggers (with a decision brief) and the decision log live in <dir>. Inspect with GET /v1/drift, decide with fuuga drift. Nothing is retrained automatically. Not applied to --continuous-batching responses (the engine computes no confidence signals). |
--drift-interval <secs> |
Seconds between drift analysis runs (default: 300; needs --drift-state) |
--memory <dir> |
Persistent session memory (off by default). See Persistent session memory |
--memory-top-k <N> |
Past exchanges recalled per request (default: 4; pinned notes always come on top) |
--memory-max-entries <N> |
Entries kept per memory session; oldest exchanges evicted first (default: 1000) |
--semantic-cache <dir> |
Reuse answers (off by default): a chat request asking what an earlier one asked gets the earlier answer without a generation. See Semantic response cache |
--cache-threshold <S> |
Cosine similarity a cached question needs to be judged (default: 0.85) |
--cache-judge-threshold <P> |
P(same question) the judge must give a candidate (default: 0.9) |
--cache-no-judge |
Serve the most similar candidate on the embedding alone (not recommended, see below) |
--cache-max-entries <N> |
Cached answers kept; the oldest go first (default: 10000) |
--cache-ttl <secs> |
Do not serve answers older than this (default: no expiry) |
--decision-model <dir> |
Decision model (fuuga decide train or fuuga import-laya) answering POST /v1/decide; it also judges --rag-rerank judge and --semantic-cache hits; without --checkpoint/--package it is served alone (/health, /v1/models, /v1/decide) and generation options are refused |
--decide-calibration <path> |
Temperature table (fuuga decide calibrate, or the one import-laya writes) for POST /v1/decide |
--decide-graph <path> / --decide-graph-facts <N> |
Knowledge-graph facts appended to each POST /v1/decide state |
--strict-window |
Refuse a decision whose state does not fit the decision model's input (400 on /v1/decide, 422 naming state on /v1/systemone) instead of deciding on its start; without it such an answer carries the cut in notes |
--help |
Show help message |
A question whose options do not fit the decision model's input is the client's error: 400 on
/v1/decide, 422 naming questions.<id>.criteria on /v1/systemone. A /v1/systemone answer
carries Jev's confidence (0 for an even split, 1 for certainty); /v1/decide reports the top
probability instead, so a threshold carried over from Jev means less there.
API Endpoints
| Method | Path | Description |
|---|---|---|
GET |
/health |
Health check |
GET |
/v1/models |
List available models |
POST |
/v1/chat/completions |
OpenAI Chat Completions (streaming + non-streaming) |
POST |
/v1/completions |
OpenAI Text Completions |
POST |
/v1/embeddings |
Embeddings |
POST |
/v1/messages |
Anthropic Messages API |
POST |
/v1/systemone |
Typed decisions in the Jev API shape ({state, model, questions} → {model, answers, usage}), on the same engine as /v1/decide |
GET |
/.well-known/agent.json |
A2A agent card |
POST |
/a2a/tasks |
A2A task submission |
GET |
/a2a/tasks/{id} |
A2A task status |
GET |
/v1/drift |
Drift monitor status: window fill, reference captured, last per-signal results, pending retraining triggers with briefs (404 unless --drift-state). With --provider-config it is /admin/drift behind the admin token instead |
GET |
/v1/memory |
The caller's memory session, oldest first (404 unless --memory) |
POST |
/v1/memory |
Pin a note: {"text": "..."} (--memory) |
DELETE |
/v1/memory/{id} |
Forget one entry (--memory) |
DELETE |
/v1/memory |
Forget the whole session (--memory) |
DELETE |
/v1/cache |
Forget the cached answers the caller's requests reach (--semantic-cache) |
POST |
/admin/credits/topup |
Add credit to a key (billing; admin token) |
GET |
/admin/credits/{key} |
One key's balance + usage (billing; admin token) |
GET |
/admin/usage |
All keys' balances + usage (billing; admin token) |
The /admin/* routes exist only when --provider-config is set.
Features
- API compatibility -- serve the OpenAI, Anthropic and Jev (typed decisions) formats side by side, or choose with
--api-compat. - SSE streaming -- token-by-token streaming for chat and text completions.
- MCP tool routing -- connect external tools via the Model Context Protocol for function calling.
- A2A protocol -- Agent-to-Agent task delegation and status tracking.
- Guard rails -- prompt injection detection, PII masking, and content filtering.
- Continuous batching (opt-in) -- with
--continuous-batching, concurrent requests are served through theContinuousBatchingscheduler over a shared paged KV cache (BatchedEngine), interleaving token-by-token instead of running one-at-a-time behind a lock. See Serving modes. - Persistent session memory (opt-in) -- with
--memory <dir>, completed turns and pinned notes are remembered per session and relevant ones recalled into later prompts. See Persistent session memory. - Microsoft.Extensions.AI -- implements
IChatClientandIEmbeddingGeneratorfor ecosystem integration.
Serving modes
By default the server processes one inference at a time behind a lock; concurrent requests queue. The startup banner reports Serving mode: sequential.
Pass --continuous-batching to serve concurrent requests through the continuous-batching engine: requests share one paged KV cache and interleave through the scheduler, so a slow long generation no longer blocks short ones. The banner reports Serving mode: continuous batching.
fuuga-serve --checkpoint checkpoints/best --tokenizer data/tokenizer --continuous-batching --port 8080
Notes and current limits (the mode is experimental):
- Sampling and guardrails apply as usual (per-request temperature / top-k / top-p / repetition penalty; input/output guardrails when
--guardrailsis set). --verifyself-verification,consensusvoting, and tool-calling are bypassed in this mode — they don't fit token-level batching.- Compute is per-sequence inside the forward pass (the scheduling interleaves; a single batched-matmul paged kernel over the whole batch is future work), so the win is latency/fairness under concurrency rather than raw single-stream throughput.
- KV-cache pool is memory-budgeted. Because the paged cache pre-allocates its blocks up front, the pool is sized to a memory budget — auto-detected by default (a fraction of free VRAM on CUDA, measured after the model loads, or of system RAM on CPU) — and the concurrent batch size is derived to fit. Override with
--cb-cache-gb <G>(orFUUGA_CB_CACHE_GB). The chosen sizing is printed at startup; if only one sequence fits at your context length, a warning suggests raising the budget.
Persistent session memory
OpenAI and Anthropic clients resend the whole conversation on every request, so within one
conversation the server needs no memory. What it lacks by default is memory across
conversations and restarts. --memory <dir> adds it; without the flag nothing is recorded
and nothing changes.
fuuga-serve --checkpoint checkpoints/best --tokenizer data/tokenizer --memory ./memory
What is remembered. Each completed
/v1/chat/completionsor/v1/messagesturn: the conversation's last user message and the final answer (each cut to 1000 characters). Turns that end in tool calls are not recorded (that is an agent mid-task, not an answer), and an answer the output guardrails block is never stored. You can also pin notes withPOST /v1/memory {"text": "..."}— facts that should always be in context. Notes are capped at 2000 characters and, under--guardrails, pass the input checks like user text (a blocked note is refused with400 guardrails_violation), since they reach every later prompt of the session as a system message.What is recalled. Every pinned note, plus the
--memory-top-kpast exchanges most relevant to the new user message (BM25 keyword relevance; exchanges sharing no word with it are left out). They go into the prompt as a system message after the client's own system prompt, within a 4000-character budget. Memory yields to the conversation: if the final prompt (including any--ragcontext) would leave too little room for the answer, the request goes out without memory instead of failing withcontext_length_exceeded. Recall is lexical, so it runs no model forward pass.Sessions. The
X-Fuuga-Session: <name>header selects the memory session (defaultwhen absent), so one server can keep separate memories per user or project. Under--provider-configsessions are additionally namespaced by the caller's API key, so one customer can never read another's memory. Each session is one JSON Lines file in<dir>, named by a hash (session names and keys never appear in paths).Per request.
X-Fuuga-Memory: readrecalls without recording (useful for CI jobs or one-off questions that should not teach the server);X-Fuuga-Memory: offdoes neither. Any other value is a400, so a typo cannot silently record a request.Managing it.
GET /v1/memorylists the session,DELETE /v1/memory/{id}forgets one entry,DELETE /v1/memoryforgets the session. These act on the caller's own session only and sit behind the same--api-key/ billing auth as the rest of/v1.Size. Sessions keep at most
--memory-max-entriesentries (default 1000); the oldest exchanges are evicted before any note.One server per directory. Each server caches sessions in memory and rewrites the files from that cache, so the directory is locked (
.fuuga-memory.lock); a secondfuuga-servepointed at it refuses to start.
Access control. Session names are labels, not secrets: anyone who can call /v1 can read,
add to or clear any session by naming it. Without --api-key that is anyone who can reach
the port (the server warns at startup). Treat the memory directory like the conversations
themselves, since it holds user text in plain JSON. For several users, give each an API key
under --provider-config (sessions are then isolated per key), or run one server per user.
Semantic response cache
A support queue or an FAQ bot answers the same questions in different words all day.
--semantic-cache <dir> lets a chat request that asks what an earlier one asked get the
earlier answer without a generation (X-Fuuga-Cache-Result: hit on the response). Without
the flag nothing is cached.
fuuga-serve --checkpoint checkpoints/best --tokenizer data/tokenizer \
--semantic-cache ./cache --decision-model models/laya-fuuga
How a hit is decided, in two stages:
- Candidates by embedding. The final user message is embedded (the vector RAG retrieval
uses too, computed once); cached questions at least
--cache-thresholdcosine-similar are candidates. Everything else about the request must match exactly: the model, the system prompt, every earlier turn, the chat template, stop sequences. An answer longer than the request'smax_tokensis not reused. - A judge confirms. The up to three most similar candidates are put to the decision
engine as a typed noul, the cached question as the state: "this question asks the same
thing as: <new question>". The first with P(true) at least
--cache-judge-thresholdis served. The judge is--decision-modelwhen given, else the served model scoring the option tags (nothing is generated). A failing judge is a miss.
Measured on 1,000 Quora question pairs (GLUE QQP validation, a hard set: every pair is on one
topic, a third are duplicates). Embeddings by the served model (Qwen3-0.6B) or by an embedder
(gte-modernbert-base, the one a --rag index records or --embedder):
| Hit rule | Hits that were the same question | Duplicates found |
|---|---|---|
| Qwen3-0.6B embedding, cosine >= 0.95, no judge | 57% | 70% |
| Qwen3-0.6B embedding, cosine >= 0.99, no judge | 54% | 10% |
| embedder, cosine >= 0.95, no judge | 82% | 21% |
| Qwen3-0.6B as the judge, best cutoff (first 300 pairs) | 58% | 77% |
| embedder, cosine >= 0.85 and Laya (zero-shot) P >= 0.7 | 81% | 55% |
| Qwen3-0.6B embedding, cosine >= 0.85 and Laya P >= 0.9: the defaults | 98% (41 of 42) | 13% |
| embedder, cosine >= 0.85 and Laya P >= 0.9: the defaults | 100% (39 of 39) | 12% |
The embedding alone cannot tell "how do I close my account?" from "how do I open one?":
neighbours in an embedding space share a topic, not always a meaning (a generative model's
more so than an embedder's). Lower --cache-judge-threshold trades precision for hits. A small
served model is a weak judge too; the server warns at startup when either is in use. A
dedicated decision model (fuuga import-laya, or one
trained with fuuga decide train on your own question pairs) is the judge to use, and
fuuga decide calibrate makes its probabilities mean what the threshold assumes.
- What is cached. Complete plain answers: no tools,
response_format,verify,consensus, thinking or assistant prefill, finish reasonstop, through the output guardrails (a blocked answer is not stored; a hit passes them again)./v1/chat/completions(streamed as one content chunk) and/v1/messages. - Whose answers. Under
--provider-configa tenant reaches only answers made for its own key. Under--memorya request that recalls memory reaches only its session's answers (they were made with that memory, and changing the session's notes forgets them); one sent withX-Fuuga-Memory: offreaches the tenant's memory-free answers. A hit is billed like the answer it replays. - Per request.
X-Fuuga-Cache: refreshgenerates and replaces;offneither reads nor writes; another value is a 400.DELETE /v1/cacheforgets the caller's answers, e.g. after the documents behind them changed. - What invalidates it. The cache file records a fingerprint of the checkpoint, chat
template, RAG index and graph files, retrieval and rerank settings and guardrails; a server
started with anything else starts with an empty cache.
--cache-ttlexpires answers by age. - Storage. One JSON Lines file in the directory, the oldest answers dropped past
--cache-max-entries(about 4 bytes per embedding dimension each, plus the text). The directory is locked to one server process (.fuuga-cache.lock).
Code review in CI/CD
The server can review pull requests in a pipeline: scripts/ci-review.fsx
sends each changed file's diff with a response_format JSON schema, so the findings come
back machine-readable, and gates the job on their severity. Setup, GitHub Actions and
Bitbucket Pipelines examples, and how to train a reviewer model are in
docs/ci-code-review.md.
Using an agentic coding client (Crush)
Crush and other clients built on
fantasy's openaicompat provider talk to
fuuga-serve directly. Start the server, then point a custom provider at it:
fuuga-serve --checkpoint checkpoints/best --tokenizer data/tokenizer --port 8080 --api-key local
{
"$schema": "https://charm.land/crush.json",
"providers": {
"fuuga": {
"type": "openai-compat",
"base_url": "http://localhost:8080/v1",
"api_key": "local",
"models": [
{
"id": "<the id from GET /v1/models>",
"name": "Fuuga local",
"context_window": 8192,
"default_max_tokens": 2048
}
]
}
}
}
Notes:
- Set
context_windowto the checkpoint's realContextLength, not an aspirational number — it's what makes the client compact the conversation before it overruns the model. A prompt that exceeds the window is rejected with400 context_length_exceededrather than failing inside the kernel. - Configure the client's small/fast model explicitly. Crush picks the provider's first model for titles and summaries, so it lands on the same endpoint and queues behind your coding requests in sequential mode.
- Don't pair this with
--continuous-batching— tool calling is bypassed in that mode (see Serving modes), which is exactly what an agentic client depends on. - Usage reporting is emitted in a trailing SSE chunk carrying no choices when the
client sends
stream_options: {"include_usage": true}, which is what clients use to track context pressure. It's withheld otherwise, per the OpenAI spec. - Prompts that exceed the model's context are rejected with
400 context_length_exceededon/v1/chat/completions,/v1/completionsand/v1/messages, rather than failing inside the generation core.
Hosting as an OpenRouter provider
Want to make money renting out a model you trained? fuuga-serve can be hosted as an
OpenRouter provider: it advertises per-token pricing on
/v1/models and meters each caller through a prepaid credit ledger — every request
is debited by token cost, and a drained key gets HTTP 402. OpenRouter is your customer
and settles with you out-of-band, so there's no payment code to write.
fuuga-serve --checkpoint checkpoints/best --tokenizer data/tokenizer \
--host 0.0.0.0 --port 8080 --provider-config provider.json
provider.json holds per-model pricing, initial per-key balances, and an admin token
(guarding /admin/credits/*). Copy provider.example.json to start.
- Full guide: docs/openrouter-provider.md — config schema, endpoints, the credit lifecycle, and security notes.
- ▶ Run it in F#:
examples/openrouter-provider.fsxdrives the billing ledger end-to-end (no model or GPU needed).
Building from Source
dotnet build # CPU (default)
dotnet build -p:TorchBackend=cuda # GPU/CUDA on Windows x64 (several GB of libtorch)
dotnet build -p:TorchBackend=cuda-linux # GPU/CUDA on Linux x64 (GPU rentals, servers)
dotnet run -- --checkpoint ../checkpoints/best --tokenizer ../data/tokenizer --port 8080
License
Unlicense (public domain). See the repository root for full license text.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
This package has no dependencies.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.41 | 134 | 10/14/2026 |
Version aligned with Fuuga 1.0.41. --semantic-cache reuses answers for requests that ask the same thing, confirmed by a judge; --rag-rerank mmr|cross|judge; --embedder and the side-model CPU options. Earlier versions: https://bitbucket.org/Thorium/fuuga/src/master/CHANGELOG.md