OpenAI-compatible LLM client, a harness that compares multiple models on the same case set, and a browser UI for the results — all in MoonBit.
Dependencies
| binary | what it does | source |
|---|---|---|
| faceoff | ask one prompt, once or streamed | cmd/faceoff |
| bench | run a suite against several models and compare them | cmd/bench |
| web server | interactive page: pick models/params, run, watch results | web/cmd/server |
| platform | command |
|---|---|
| macOS / Linux | curl -fsSL https://cli.moonbitlang.com/install/unix.sh \| bash |
| Windows (PowerShell) | Set-ExecutionPolicy RemoteSigned -Scope CurrentUser; irm https://cli.moonbitlang.com/install/powershell.ps1 \| iex |
| VS Code | command palette → MoonBit:install latest moonbit toolchain |
moon versiongit clone <this repo> && cd moonbit-llm-faceoff
moon run --target native scripts/demo.mbtx==> 1/3 one-shot
航空母舰是一种以舰载机为主要作战武器的大型水面舰艇。
==> 2/3 streaming (fragments arrive one by one)
侧风掠过甲板,把雨线吹成斜的。
==> 3/3 comparing two models
model mock-a
runs 1 failures 0 truncated 0 retried 0
first token (ms) 1 0 ...
...make serve # builds the page, then starts the server from web/
# → http://127.0.0.1:8137/moon run --target native scripts/build-web.mbtx
cd web
../_build/native/debug/build/web/cmd/server/server.exeexport MOONLLM_BASE_URL="https://api.deepseek.com/v1" # or any compatible base URL
export MOONLLM_MODEL="deepseek-chat"
export MOONLLM_API_KEY="sk-..."
client=./_build/native/debug/build/cmd/faceoff/faceoff.exe
$client "用一句话说明什么是航空母舰"
$client --stream "写一首关于侧风的短诗"# 1. build once (the demo in step 2 builds as well)
make deps
MOON_CC=gcc moon build --target native
# 2. point at your gateway — export it, never commit it
# any OpenAI-compatible service will do
bench=./_build/native/debug/build/cmd/bench/bench.exe
export MOONLLM_BASE_URL="https://<your-gateway>/v1"
export MOONLLM_API_KEY="sk-..."
# 3. write the suite: JSON Lines, one case per line, `prompt` is the only
# required field (bench/cases.example.jsonl is a fuller example)
mkdir -p my-run
cat > my-run/cases.jsonl <<'JSONL'
{"id": "math-short", "prompt": "计算 17 × 23。只输出数字。", "max_tokens": 512, "temperature": 0.0}
{"id": "fact-zh", "prompt": "用一句话说明什么是航空母舰。"}
JSONL
# 4. compare — the same suite, one model at a time
$bench \
--models <model-a>,<model-b> \
--cases my-run/cases.jsonl \
--repeats 3 --max-tokens 2048 --temperature 0.0 \
--pace-ms 1000 --retry 2 \
--json my-run/runs.jsonl
# 5. render it as a self-contained page — no server, no key
moon run --target native scripts/build-web.mbtx my-run/runs.jsonl # → web/out/report.html
# 6. the same loop again, with assertions and a written record
moon run --target native scripts/real-gateway.mbtx # → docs/real-gateway-run.md| probe | question it answers |
|---|---|
| one-shot | does the endpoint return a real answer at all |
| streaming | do fragments really arrive incrementally — first byte vs process exit, the same measurement the offline tests use |
| bad key | is an auth failure reported as 4xx, and is the key kept out of the error |
| truncation | does a reply cut off by --max-tokens land in the truncation counter instead of passing as a success |
| you see | it is |
|---|---|
| moon.mod | the module manifest — one per repository, like package.json or Cargo.toml |
| moon.pkg | the package manifest — one directory = one package, listing that package's imports |
| *.mbt | source files; foo_test.mbt / foo_wbtest.mbt are blackbox / whitebox tests |
| *.mbtx | a single-file script — moon run --target native file.mbtx, no module or package manifest needed. Used here for the test utilities under scripts/ |
| _build/ | build output (_build/native/debug/build/.../main.exe) |
| moon build / run / test / check | build, run, test, type-check |
| symptom | fix |
|---|---|
| failed to resolve native archiver executable /usr/bin/lib.exe, or new native backend requires a C compiler/linker driver | a C compiler exists but wasn't picked up. Set it explicitly: MOON_CC=gcc moon build --target native. All scripts here already default to MOON_CC=gcc. |
| Cannot find import '...' | stale registry index: run moon update |
| browser tests fail with cannot open shared object file | the browser binary is older than the system libraries it links against. Check with ldd $(command -v chromium), or point the tests elsewhere with CHROME=/path/to/chrome |
# the build puts the binaries under _build/; name them once per shell
faceoff=./_build/native/debug/build/cmd/faceoff/faceoff.exe
bench=./_build/native/debug/build/cmd/bench/bench.exe
# one-shot
$faceoff "用一句话说明什么是航空母舰"
# streamed, printed as fragments arrive
$faceoff --stream "写一首关于侧风的短诗"
# prompt from stdin
echo "总结一下这段日志" | $faceoff --stream| flag | meaning |
|---|---|
| -s, --stream | stream the reply as it is generated |
| --show-cot | stream the chain of thought too — on stderr, so a pipe stays clean |
| -q, --quiet | no status lines on stderr |
| --model <id> | model id |
| --base-url <url> | API base URL |
| --api-key <key> | bearer token |
| --system <text> | system prompt |
| --temperature <t> | sampling temperature |
| --max-tokens <n> | maximum generated tokens |
| --timeout-ms <n> | per-request timeout (default 60000; one-shot path only) |
| --no-key | allow an empty API key (local endpoints) |
| -- | treat every following argument as prompt text |
| -h, --help | print the usage above |
$ faceoff --max-tokens 64 "用一句话说明什么是甲板风。"
-> POST https://api.example.com/v1/chat/completions model=some-model max_tokens=64
<- 3.2s content 41 chars reasoning 512 chars tokens 21+64(reasoning 64) finish_reason=length$ faceoff --max-tokens 64 "..." >answer.txt
$ echo $?
1| setting | variables | default |
|---|---|---|
| API key | MOONLLM_API_KEY, OPENAI_API_KEY, LLM_API_KEY | — |
| base URL | MOONLLM_BASE_URL, OPENAI_BASE_URL | https://api.openai.com/v1 |
| model | MOONLLM_MODEL, OPENAI_MODEL | gpt-4o-mini |
| system | MOONLLM_SYSTEM | You are a helpful assistant. |
$ $faceoff --api-key wrong "hi"
error: http 401: {"error":{"message":"invalid api key"}}$bench \
--base-url https://api.modelbest.cn/v1 --api-key "$MB_KEY" \
--models MiniCPM5-1B,MiniCPM5-2B \
--cases bench/cases.example.jsonl \
--repeats 3 --max-tokens 2048 --temperature 0.0 \
--pace-ms 3000 --retry 3 \
--json my-run/runs.jsonl$bench --model MiniCPM5-1B --prompt "用一句话说明什么是航空母舰" --show-cot{"id": "math-short", "prompt": "计算 17 × 23。只输出数字。", "max_tokens": 512, "temperature": 0.0}| metric | meaning |
|---|---|
| first token | until the first fragment of anything, reasoning included |
| first answer | until the first fragment of the visible answer, i.e. after the chain of thought |
| total | until the stream ends |
| decode tok/s | completion_tokens over the window after the first token — the closest thing to decode speed a client can observe |
| end-to-end tok/s | completion_tokens over the whole request |
| reasoning tokens / reasoning share | how much of the budget went into thinking |
| failures / retried / truncated | rate limits, server errors, replies cut off by max_tokens |
# no network, no API key needed
$bench --from-json bench/results-example.jsonl --no-key --web-data web/data.json| flag | what |
|---|---|
| --json <file> | every raw attempt as JSON Lines, including the full answer and reasoning text |
| --web-data <file> | the page-data document the page renders (schema in bench/pagedata.mbt) |
| --show-cot | stream chain-of-thought text to stderr as it arrives |
| --show-output | print each answer to stderr when the run settles |
| --progress <file> | append live progress events (JSON Lines) while running — this is what the page reads to show a run in flight |
jq -r 'select(.case_id=="code-python") | "\(.model)\t\(.completion_tokens)\t\(.content)"' \
bench/results-example.jsonlmoon run --target native scripts/build-web.mbtx # from the repository root
cd web # the server resolves out/, runs/ and the case file
# relative to its working directory — run it here,
# not from the repository root
MOONLLM_API_KEY=... \
MOONLLM_BASE_URL=https://api.modelbest.cn/v1 \
LLM_WEB_MODELS=MiniCPM5-1B,MiniCPM5-2B \
../_build/native/debug/build/web/cmd/server/server.exe
# → http://127.0.0.1:8137/http://127.0.0.1:8137/?models=mock-a,mock-b&cases=math-short,fact-zh&repeats=3| endpoint | purpose |
|---|---|
| GET /api/meta | {models, caseSets, defaultCaseSet, cases, defaults, hasKey, baseUrl}. cases is the default set's list, kept because the URL-config path reads it |
| GET /api/runs | the run history, newest first: {runs: [{id, startedAt, status, exitCode, request, total, done, failures, retried, truncated}]} |
| POST /api/runs | start a run → {id, total}. Three mutually exclusive ways to say what to run: caseSet + cases (ids from a set on disk), a single prompt, or inlineCases (an array of case objects, written into that run's cases.jsonl). system sets the system prompt for the whole run; a case may override it with its own |
| GET /api/runs/<id> | {status, done, total, exitCode?, tail, failures, retried, truncated, data?, error?} |
| DELETE /api/runs/<id> | remove that run's directory (its annotations go with it) |
| POST /api/cases/<name>/import | create a set from a text file: {"path": "..."}, one prompt per line (a .jsonl case set is accepted as-is) |
| GET /api/runs/<id>/context | what the run actually sent: the request document plus each case's effective system / maxTokens / temperature (systemOverridden marks a case-set prompt that beat the global one) |
| GET /api/runs/<id>/annotations | {id, annotations: [{case_id, model, verdict, note}]} — the human verdicts, verdict being pass / fail / unsure |
| PUT /api/runs/<id>/annotations | whole-list write; one entry per (case, model), duplicates are a 400 |
| GET /api/runs/<id>/runs.jsonl | the raw per-attempt log, as a download |
| GET /api/runs/<id>/data.json | the page-data document, as a download |
| GET /api/runs/<id>/report.html | a self-contained static report, generated on first request |
| GET /api/cases | {sets: [{name, count}]} |
| GET /api/cases/<name> | {name, cases: [...]} — the raw case records, all fields |
| PUT /api/cases/<name> | whole-set write ({cases: [...]}); an unknown name creates the set |
| DELETE /api/cases/<name> | remove a set |
| GET /api/presets | {presets: [...]} |
| PUT /api/presets | whole-list write ({presets: [...]}) |
web/runs/<id>/
request.json what was asked for
cases.jsonl the filtered suite (when cases were selected)
runs.jsonl bench's raw output, grows as it runs — progress is its line count
stdout.log bench's stdout (the rendered report)
stderr.log bench's progress log
exit_code written when the bench process is reaped; its presence means "finished"
data.json the final page-data document| variable | default |
|---|---|
| LLM_WEB_PORT | 8137 |
| LLM_WEB_STATIC | out |
| LLM_WEB_WORK | runs |
| LLM_WEB_CASES_DIR | cases — one <name>.jsonl per case set |
| LLM_WEB_CASES | ../bench/cases.example.jsonl — only a seed for cases/default.jsonl |
| LLM_WEB_PRESETS | presets.json |
| LLM_WEB_MODELS | — (no menu; type model ids in the box instead) |
| LLM_WEB_SYSTEM | a system prompt to prefill the run-level box with. Set it once and every run starts from it (the page shows it, so you can still change it per run) |
| LLM_BENCH_BIN | ../_build/native/debug/build/cmd/bench/bench.exe |
| LLM_WEB_SSG | _build/native/debug/build/cmd/ssg/ssg.exe |
| MOONLLM_API_KEY / OPENAI_API_KEY | — |
| MOONLLM_BASE_URL / OPENAI_BASE_URL | — (the page asks for one) |
moon run --target native scripts/build-web.mbtx path/to/other-runs.jsonl # → web/out/report.htmlimport {
"conglinyizhi/moonbit-llm-faceoff" @faceoff,
}// one-shot
let settings = @faceoff.Settings::from_env(env)
let reply = @faceoff.ask(settings, "用一句话说明什么是航空母舰")
// the same request, keeping what the reply says about itself: an empty answer
// is not a bug report, and the stop reason and token counts explain it
let outcome = @faceoff.ask_outcome(settings, prompt)
// outcome.content, outcome.reasoning, outcome.usage, outcome.finish_reason
// streaming, with reasoning fragments separated from the answer
let outcome = @faceoff.stream_parts(settings, prompt, async fn(part) {
match part {
Content(text) => handle_answer(text)
Reasoning(thought) => handle_thought(thought)
}
})
// outcome.content, outcome.reasoning, outcome.usage, outcome.finish_reason// benchmarking
let cases = @bench.parse_cases(text)
let results = @bench.run_bench(settings, models, cases, options, on_start, on_part, on_result)
let summaries = @bench.summarize_all(models, results)
println(@bench.format_summaries(summaries))make ci # two phases: check + unit tests, then smoke ∥ api ∥ web
make e2e # the browser test as well — needs chromium, and it is slow
make # list every targetmoon test --target native # 85 unit tests, no network
moon run --target native scripts/smoke.mbtx # CLI end-to-end against a local mock endpoint
moon run --target native scripts/server-api.mbtx # server HTTP contract, incl. key handling
bash scripts/web-e2e.sh # browser end-to-end (headless chromium)| suite | covers |
|---|---|
| moon test | settings resolution and precedence, flag parsing and error cases, request JSON shape, response decoding (one-shot outcome: content / reasoning / usage / stop reason), SSE framing (content / reasoning / usage / finish / [DONE] / CRLF / malformed), case-file parsing, statistics, throughput derivation, run round-trip, page-data contract, key masking in an upstream error body |
| scripts/smoke.mbtx | one-shot via env and via flags, streaming, stdin prompts, incremental delivery, non-ASCII error-body decoding, auth failures, that a failing auth does not echo the key, status lines on stderr with stdout left alone, --quiet, --show-cot, an empty reply warned about and non-zero instead of a blank line, and the bench harness against the same mock: a 429 that clears is retried for real (attempts: 2), --retry n means n extra HTTP attempts, and a finish_reason: length reply lands in the truncation counter instead of passing as a success |
| scripts/server-api.mbtx | a run whose baseUrl/apiKey come from the request body while the server's own are deliberately broken, model ids outside the menu, the live counters, all three exports, that the key never lands in the run directory or the response, and that path traversal is refused |
| scripts/web-e2e.sh | a real headless browser: the form renders from /api/meta (including the model / gateway / key inputs), an ?autorun link actually completes a run and renders its results (including the chain of thought folded under every answer), the run-history rail lists that run and opening it switches to the read-only view, editing a case in the page reaches the file on disk and a preset saved in the page shows up in the list, ticking two runs opens the comparison (parameter diff, metric deltas, per-case answers), deleting a run drops it from the list, eight parallel POST /api/runs come back with eight distinct ids, the start button is usable again once the run finishes, and the export row yields a Markdown report and a share link that carries no key |
| scripts/real-gateway.mbtx | the one suite that is not offline and not in CI. Four probes against a real endpoint: one-shot, incremental streaming, a bad key reported as 4xx without echoing it, and a reply cut off by --max-tokens counted as truncated. Writes docs/real-gateway-run.md. Needs MOONLLM_BASE_URL / MOONLLM_API_KEY exported |
moon.pkg library package imports (native only)
faceoff.mbt package documentation
settings.mbt Settings + ConfigError, environment resolution
cli.mbt Cli::parse, usage text
api.mbt request building, response/SSE decoding
runner.mbt ask / stream_chat / stream_parts / stream_to_stdout
*_test.mbt blackbox unit tests
*_wbtest.mbt whitebox unit tests (internal helpers)
bench/ the measurement harness
bench.mbt entry point
case.mbt suite parsing
runner.mbt run_case / run_bench, retry, JSON round-trip
metrics.mbt Stats, summarize
report.mbt the human-readable report
pagedata.mbt the document the page consumes
cli.mbt bench flag parsing
cmd/faceoff/ the faceoff executable
cmd/bench/ the bench executable
web/ the page, its server and the report (Rabbita + precss)
shared/ data model + result components (js + native)
cmd/app/ interactive page (js, Rabbita TEA)
main.mbt the form, the run in progress, the results
history.mbt the run-history rail
manage.mbt case sets and presets
compare.mbt two runs side by side
cmd/server/ static + API server (native)
main.mbt routing and handlers
store.mbt case sets, presets, run history — files and JSON
cmd/ssg/ static report (native)
cmd/build/ the page build entry point (native)
styles/ site.scss → precss → site.css
web/cases/ your case sets (one <name>.jsonl each) — gitignored
web/presets.json your model + parameter combinations — gitignored
web/runs/ one directory per run — gitignored
web/data.json page data exported from a run — gitignored
web/out/ built page + static report, from scripts/build-web.mbtx
web/shell/ index.html shell for the interactive page
scripts/
demo.mbtx offline, no-API-key demo of all three paths
mock_openai.mbtx offline OpenAI-compatible endpoint, as a MoonBit script
check_incremental.mbtx measures that --stream really streams
smoke.mbtx CLI end-to-end
web-e2e.sh browser end-to-end
cdp-dump.mjs drives headless chromium over the DevTools protocol
lib.sh helpers shared by the scripts above
cdp-dump.mjs DevTools-protocol DOM dump helper
docs/ library survey, benchmark notes| package | license | used for |
|---|---|---|
| moonbitlang/async | Apache-2.0 | HTTP client, streaming reads, the local server behind the page, and the concurrency the harness runs on |
| moonbit-community/rabbita | Apache-2.0 | the page app and the static report generator |
| conglinyizhi/precss | Apache-2.0 | compiling web/styles/site.scss |
pub(all) suberror ClientError {
Transport(String)
Status(code~ : Int, message~ : String)
Decode(String)
}pub(all) suberror ConfigError {
MissingApiKey
MissingValue(flag~ : String)
BadNumber(flag~ : String, value~ : String)
UnknownFlag(flag~ : String)
}pub(all) struct AskOutcome {
content : String
reasoning : String
usage : TokenUsage?
finish_reason : String?
}pub(all) struct Cli {
settings : Settings
prompt : String?
stream : Bool
show_cot : Bool
quiet : Bool
show_help : Bool
}pub(all) struct Settings {
api_key : String
base_url : String
model : String
system : String
temperature : Double?
max_tokens : Int?
timeout_ms : Int
enable_thinking : Bool
reasoning_effort : String?
} derive(Eq)pub(all) enum SseEvent {
Content(String)
Reasoning(String)
Usage(TokenUsage)
Finish(String)
Done
Ignore
} derive(Eq, Debug)pub(all) struct StreamOutcome {
content : String
reasoning : String
usage : TokenUsage?
finish_reason : String?
}fn TokenUsage::new(prompt_tokens : Int, completion_tokens : Int, reasoning_tokens : Int) -> TokenUsageasync fn read_prompt_from_stdin() -> Stringasync fn stream_chat(settings : Settings, prompt : String, on_delta : async (String) -> Unit) -> String raise ClientErrorasync fn stream_parts(settings : Settings, prompt : String, on_part : async (StreamPart) -> Unit) -> StreamOutcome raise ClientErrorInstall
Download zipOpenAI-compatible LLM client, a harness that compares multiple models on the same case set, and a browser UI for the results — all in MoonBit.
Dependencies