Trace Ecosystem (v0.63.0)

Pull traces from any SaaS observability dashboard, mine prompts, sample the most uncertain rows, run sequential A/B with martingale-controlled Type-I error, and watch production for distributional drift — all from one CLI, all offline, no per-trace fees.

Five new top-level commands, every one of them LIVE in v0.63.0.

soup ingest — universal trace importer

bash
cp ~/Downloads/langfuse_export.jsonl ./langfuse_export.jsonl
soup ingest --source langfuse --logs ./langfuse_export.jsonl \
  --output ./traces.jsonl

--logs and --output must both resolve inside the current directory, so a path under ~/Downloads is refused (exit 1, "outside cwd") unless you happen to run from there. Copy the export into the project first, as above.

Six sources at launch:

SourceEnv varNotes
langfuseLANGFUSE_PUBLIC_KEY + LANGFUSE_SECRET_KEYDashboard export, or a live pull (below)
langsmithLANGSMITH_API_KEYLangSmith API traces
heliconeHELICONE_API_KEYHelicone observability
openpipeOPENPIPE_API_KEYOpenPipe production traces
otelOTEL_EXPORTER_OTLP_HEADERSOpenTelemetry OTLP
openai-storedOPENAI_API_KEYOpenAI Stored Completions

Langfuse needs a key pair, not one key. The hint this path printed used to name LANGFUSE_KEY, which is a variable Langfuse does not read at all; it names LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY as of v0.75.0. If you followed the old hint and got nowhere, that is why.

By default there are no network calls: you export from the SaaS dashboard, then soup ingest normalises the file. Langfuse is the one source Soup can also fetch for itself, with --pull (below). A PII warning prints once per invocation either way.

Live pull from Langfuse (v0.75.0)

bash
export LANGFUSE_PUBLIC_KEY=pk-lf-...
export LANGFUSE_SECRET_KEY=sk-lf-...

soup ingest --source langfuse --pull --since 24h --output traces.jsonl

No export step. --pull replaces --logs and reads Langfuse's Observations API v2 directly, writing one row per GENERATION observation into the same TraceRecord schema the file path produces. A chat message list becomes a prompt of every message's content joined by newlines, including the system prompt, which is the same flattening the local parser already applies to a {"messages": [...]} export, so the two paths agree. An agent trace therefore yields one row per LLM call it made, and its spans and tool calls yield none. A row's trace_id is the observation id.

FlagDefaultNotes
--pulloff--source langfuse only
--since7d30m, 24h, 7d; maximum 365d
--max-pages100Pages of 100 generations. Maximum 10,000
--allow-private-hostoffFor a self-hosted Langfuse on a private, loopback or shared-address (100.64.0.0/10, which includes Tailscale) host. The shared-address range needs it as of v0.75.2, and so does the deprecated IPv6 site-local block fec0::/10. HTTPS is still required

Credentials never take a flag, so they never reach the audit log's argv. They come from LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY, with LANGFUSE_BASE_URL or LANGFUSE_HOST for another region or a self-hosted instance (LANGFUSE_BASE_URL wins, matching the Langfuse SDK's own precedence), and they are kept out of the output file, the console, debug logs, the audit log and error messages. A host URL with credentials embedded in it is refused, naming the two variables instead.

Every loop is bounded, and the bounds are the point:

  • HTTPS only, through the same SSRF validator --slack-url uses. Redirects are refused rather than followed with credentials attached.
  • 30 s per request, enforced as a wall-clock deadline covering the connection and the whole response (v0.76.0). Before, the timeout reached the socket only, so a server that sent a byte before every timeout kept the call alive. The 64 MiB per response cap is read in chunks, so the deadline is checked while bytes are still arriving.
  • A repeated pagination cursor stops the pull with an error (returned a pagination cursor it had already sent). It used to run on to --max-pages.
  • Only generations become rows. type=GENERATION is sent as a query parameter, and each observation's own type is checked as well, because a server may ignore the parameter. Observations that are not generations are skipped and counted: N observation(s) were not generations and were skipped.
  • Hitting --max-pages exits 1 and writes nothing rather than handing you a silently truncated dataset. Output streams to a staging file, so a failed pull leaves an earlier file at --output untouched.
  • HTTP 429 is retried up to five times honouring Retry-After, capped at 60 s.

Generations with no input or no output are skipped and counted in the summary line, so a pull that matched nothing usable says so instead of writing an empty file. It uses the standard library over HTTPS, so there is no new dependency, and without --pull the pull code is never imported and no connection is opened.

One timing note worth acting on: it reads the v2 Observations endpoint because /api/public/traces leaves Langfuse Cloud on 16 November 2026.

LangSmith, Helicone, OpenPipe and OpenAI Stored Completions have no live pull yet. Export them and pass --logs.

Output schema (frozen TraceRecord with MappingProxyType metadata):

json
{
  "trace_id": "trace_abc123",
  "prompt": "What is the capital of France?",
  "output": "The capital of France is Paris.",
  "source": "langfuse",
  "signal": "none",
  "metadata": {"user_id": "user_789", "timestamp": "2026-05-20T..."}
}

Feeds directly into v0.26 soup data from-traces for preference-pair building.

soup prune-prompt — system-prompt mining

bash
soup prune-prompt --input ./traces.jsonl --output ./traces_pruned.jsonl --min-frequency 0.95

Detects the longest shared system-prompt prefix across rows via binary search over up to 32 candidate lengths (v0.63 fixed an O(N²) early-exit bug from the prototype). Strips it from training data so the fine-tuned model internalizes the boilerplate instead of repeating it — OpenPipe's signature trick.

Pass --tokenizer <model> and the prefix is found over token ids rather than characters. That is not a refinement: a character-level strip can cut a BPE multi-byte sequence in half when the shared prefix ends mid-token, and the tokenizer-aware mode finds the longest shared token-id prefix and decodes what remains. --slack-url and --discord-url post the result through the same SSRF-hardened webhook validator the rest of the CLI uses.

Two-pass file read; capped at 100k rows to prevent DoS.

soup data active-sample — uncertainty sampling

bash
soup data active-sample --input ./prod_traces.jsonl --budget 200

Two modes auto-detected from the JSONL schema:

  • Single score — max-entropy on rm_score (peaks at 0.5).
  • Dual scores — pairwise disagreement on rm_scores list.

Output is a drop-in eval prompt set for human judging.

soup ab: two-sided sequential A/B test

bash
soup ab --input ./ab_results.jsonl --metric judge_score \
  --alpha 0.05 --effect-size 0.1

This section describes the test as it is at v0.76.0. Through v0.75.2 soup ab ran an mSPRT, a Gaussian-mixture likelihood ratio read against Wald's two boundaries, with --beta as the second error rate. That statistic, those boundaries and the --beta 0.20 in the old example are gone: --beta is retired (below), and the sequential test is a different one.

You can re-run soup ab after every new row and stop at the first reject_h0 or accept_h0. The chance of a false reject_h0, and of a false accept_h0, each stays at most --alpha however often you look, unlike a naive repeated t-test, which inflates it. The statistic is a Bayes factor that averages over both the size of the difference and the unknown row-to-row spread (a normal-inverse-gamma mixture). It is two-sided, so a difference of either sign is equal evidence, and it has no fixed burn-in: the first 5 rows per arm set the scale and a verdict is possible from the 7th row per arm, but at the default --alpha a reject_h0 that early needs a very large, clean difference (in a trial run for this page, differences of 4 and of 8 standard deviations at 7 rows per arm both still read continue). It rejects H0 once the Bayes factor reaches 1 / --alpha. It accepts H0 when the confidence sequence for the difference, valid however often you look, lies inside (-effect_size, +effect_size). The guarantee assumes roughly Gaussian rows. The accept rule is read from the same mixture, so --alpha bounds both a wrong reject_h0 and a wrong accept_h0; to make accept_h0 stricter, lower --alpha. Upstream's two public records for this test are in the benchmarks directory (gate-1265-ab-nig.md and gate-1418-ab-cs-accept.md); they are simulated Gaussian rows, so read them as properties of the test, not of your metric.

--beta is retired. Passing it prints Warning: --beta no longer does anything (#1418) ... Remove --beta; to make accept_h0 stricter, lower --alpha. Soup v0.77 will refuse it. and the run continues (exit 0). Delete it from scripts now: upstream's printed deadline is that v0.77 refuses it.

--slack-url and --discord-url go through the same SSRF-hardened validator the rest of the CLI uses, and the interesting half is when they stay quiet: the webhook fires only when the test actually decides, accepting or rejecting the null. A run that is still gathering evidence says nothing. The same pair is on soup ingest, soup prune-prompt and soup data active-sample. The request body is the verdict as a flat JSON object (command, metric, decision, direction, log_likelihood_ratio, n_control, n_treatment, mean_control, mean_treatment) with no text or content key, and delivery needs httpx. Whether a particular Slack or Discord hook accepts a body of that shape has not been checked here, and a failed delivery prints only slack webhook: failed.

Input rows (the numeric field must be named after --metric, here judge_score):

json
{"arm": "control", "judge_score": 7.4}
{"arm": "treatment", "judge_score": 8.2}

Decisions: reject_h0 / accept_h0 / continue. A reject_h0 carries a direction, better or worse, read through the metric's polarity: judge_score is higher-is-better, and latency and retry_rate are lower-is-better. The direction row reads n/a on accept_h0 and continue. On a 12-versus-12 fixture whose treatment mean was 0.46 against 0.72, the panel read decision reject_h0, direction worse, prior_scale first 5 rows per arm, log_likelihood_ratio 5.0670, and Significant difference detected: the treatment is worse than control on judge_score (higher is better). Recommend rollback: keep serving the control. The log_likelihood_ratio is the statistic of the reject rule; next to an accept_h0 it can be anything below log(1 / --alpha), so do not read it as the accept evidence. (v0.63 shipped a fix for a sign error in the then-current implementation; that implementation has since been replaced.)

Three metrics: latency, judge_score, retry_rate. Any other metric name exits 2, as does an --alpha outside (0, 1) or an --effect-size of 0 or less.

What the test needs from the file:

  • arm must be exactly control or treatment (Control is skipped). A row with any other arm, a non-numeric or non-finite value in the metric field, or invalid JSON is skipped without a message, and the metric name must match the field exactly: a latency file run with --metric judge_score finds no rows and exits 0.
  • The first 5 rows of each arm set the scale and are not otherwise tested. --effect-size is in the metric's own units, but the statistic needs it in standard deviations, so the pooled standard deviation of those rows converts it (the verdict table shows prior_scale). The reported means and counts still cover every row. If an arm's first rows are all identical, more rows are held out until one differs. Order therefore matters. Until each arm has the 5 and then 2 more, the verdict is continue with Not enough rows yet: the first 5 rows of each arm set the scale of --effect-size, and the test needs 2 more per arm after them.
  • A held-back accept_h0. If the first rows barely vary (a warm cache, or a judge that gives the same score early), accept_h0 is withheld as continue while the tested rows spread more than 3 times as much as the held-out ones. Those rows are fixed, so more rows of the same kind never release the hold: the panel prints Held back: the tested rows spread N times the held-out ones ... and tells you to drop those rows or reorder the input so the first rows vary. A reject_h0 is never held back. Thirty identical rows per arm stay continue with Insufficient evidence.
  • --effect-size defaults to 0.1 in the metric's own units, which is meaningless for a latency in milliseconds: a 100 ms versus 140 ms comparison at the default stayed continue, and decided reject_h0 at --effect-size 10.
  • retry_rate should be pre-aggregated per prompt: the test assumes roughly Gaussian values, so feed it per-prompt rates rather than a 0/1 per attempt.
  • The exit code is 0 for every decision, continue, reject_h0 and a worse treatment included, and there is no JSON output. Unlike soup drift-alarm (exit 3 on drift) it is not a CI gate by exit code; read the printed decision and direction, or use the webhook.

soup drift-alarm — KL drift watch with webhooks

bash
soup drift-alarm \
  --reference ./ft_output_dist.jsonl --live ./prod_output_dist.jsonl \
  --threshold 0.2 \
  --slack-url "https://hooks.slack.com/services/T.../B.../..."

Rolls KL divergence between word-frequency snapshots of two JSONL files: model outputs from fine-tune time (--reference) and from production (--live). Each row is one model output; Soup splits it on whitespace and compares the two distributions (a word missing from one side gets a small epsilon, so the divergence stays finite). It surfaces both behavioral drift ("model now outputs JSON") and vocabulary drift ("same 20 phrases repeated"). There is no pluggable tokenizer: whitespace splitting is the only mode, and the soup drift-alarm options are --reference, --live, --threshold (default 0.2), --slack-url and --discord-url. Both files must sit under the working directory.

Input rows (one per model output; the text may be under output, response or text):

json
{"output": "The capital of France is Paris."}

Check n_reference and n_live in the panel. A row without one of those string fields is skipped silently, and when either file yields no usable rows the command reports KL 0.0000, OK and exit 0. A file of the wrong shape (for example per-token {"token": ..., "log_prob": ...} rows) therefore looks like a healthy model, which is the worst way for an alarm to fail.

Optional Slack / Discord webhooks fire on drift and are SSRF-validated: plain HTTP is accepted only for loopback, HTTPS hosts are accepted, and private, link-local, cloud-metadata, shared-space (100.64.0.0/10) and deprecated site-local (fec0::/10) IP addresses are rejected on both schemes. Hostnames are not resolved. Exit code 3 on drift for cron-friendly automation.

Numbers

+219 new tests in v0.63.0 (9816 → 10035). Security: 1 CRITICAL (mSPRT sign error → Wald SPRT), 2 HIGH, 3 MEDIUM, 2 LOW. (That statistic was replaced again in v0.76.0; see soup ab above.)

See also

  • Soup loop — the HarvestFn half of the v0.58 production flywheel is now powered by soup ingest.
  • Trace-to-preference — convert normalised traces into DPO / KTO pairs.
  • Eval design — derive a goal-conditioned eval suite from the active-sampled prompts.

Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.