All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
- Bounded capture suites. Two optional settings under
captures:,max_age: 14dandmax_cases: 200, together keep each captured suite a capped, route-balanced sample of recent production ondefaults.source_model. Setting either one turns the feature on; without themcapture synckeeps and promotes exactly what it did before. - Filter. With either limit set,
capture syncpromotes only captures recorded ondefaults.source_model, and withmax_ageset, only captures recorded within it. Model ids match on bare names: lower-cased, provider prefix and trailing snapshot date dropped, no alias lookup. A conversation turn that recorded no messages list of its own is promoted only when every earlier turn of its conversation is local, since its history is rebuilt from them (not eligible: N missing earlier turns, with a warning naming the likely causes: the fetch window or budget,capture clean, the SDK's per-turn sample rate, or aturn_indexnumbered from 1).max_agealone caps nothing.capture promoteignores the limits. - Plan. With
max_casesset, sync never grows a suite past the cap and swaps stale cases (too old, or recorded on another model) for fresh captures as they arrive. A stale case leaves at once when a fresh capture of the same question is added in its place; if its fresh copy is not added, the stale case leaves only to make room, like any other stale case. So a suite never shrinks because its cases went stale (an over-cap suite is still trimmed tomax_cases). A conversation enters whole when it fits; once no whole conversation fits, the slots left take the next one's leading turns, so one long conversation cannot leave a suite nearly empty. Cases a human markedprovenance: reviewedare never retired; in a captured suite the mark goes in the case file (.evalshift/suites/<suite>/<case>.json), not ingolden.jsonl, which sync regenerates. Sync knows a case by its file, so a renamed or copied case file is retired (or kept) on its own. The same captures give the same suite on any machine, and syncing again with nothing new changes no file.max_casesalso trims suites sync did not build: suites promoted by hand withcapture promote, and suites whose entry ismanaged: false(that flag freezes the YAML entry, not the case files). A suite whose case files cannot be read is left untouched, with a warning, and the other suites still sync. A suite planned only because its directory holds case files is wired intosuites:only if it already has an entry there. - Guardrail. If none of a suite's cases and captures was recorded on
defaults.source_model, sync warns (none of N case(s) and capture(s) were recorded on <model> …; not filtering by model — check defaults.source_model) and does not filter that suite by model rather than empty it. - Fetch. The limits also bound what the automatic fetch of
capture syncandcapture listdownloads fromcaptures.store:max_ageskips older captures, andmax_casestakes only each suite's newest3 × max_casescaptures inside that window (skipped 2400 beyond each suite's newest 600). Captures already local or promoted count toward that budget, so each sync downloads at most that many per suite, however many captures the bucket holds, and a re-sync with nothing new downloads nothing. Toolset sidecars are always fetched;--sincereplaces the age cutoff, not the budget;capture fetchis unchanged. - Output. Sync prints one line per suite:
alpha: 200 cases — kept 150, retired 50 (32 older than 14d, 18 on another model), added 50 of 2940 eligible; not eligible: …. Withmax_casesset, a capture's build warnings print only when it becomes a case. doctorgainssuite freshnessrows for eachsource: capturedsuite when either limit is set:ok(alpha: 200 cases, captured 2d–12d ago), or onewarnper finding — the newest case is older thanmax_age, cases were recorded on another model thandefaults.source_model(or none on it), the suite is overmax_cases, or case files could not be read. Acaptures limitswarning fires whencaptures.storeis set andcaptures.max_casesis not (suites grow with every capture sync; set captures.max_cases and captures.max_age).compareprints the same findings above the verdict for the suite it runs, named with--suite-nameor auto-selected (not for--suite <path>), so a team that only runs CI sees its suite went stale.- Promoted case files record
captured_atandsource_model, with or without the limits.capture syncbackfills them on existing cases whose capture is still local (backfilled capture time and model on N case(s)).
evalshift initscaffoldscaptures: {max_age: 14d, max_cases: 200}(with a commentedstore:). Existing configs are untouched. Without the two keys,capture synckeeps and promotes exactly what it did before.capture syncnow readsevalshift.yamlunder--offlinetoo, for the limits. When the file cannot be read and has a top-levelcaptures:key, sync says so (could not read <path> (<reason>); syncing without captures.max_age / captures.max_cases.), online and offline.- The first
capture syncon this CLI writescaptured_atandsource_modelinto case files, even without the limits, and older CLIs reject case files carrying them (and the newcaptureskeys). Everyone who runscapturecommands, teammates and CI alike, upgrades together: bump the CLI (and a pinned CI action'sevalshift-version) everywhere before the first sync.captures.max_ageaccepts at most36500d; omit it for no age limit. CI'scomparereads onlygolden.jsonl, whose format is unchanged. A run left in progress by an older CLI cannot be--resumed across the upgrade (config-hash mismatch); start a fresh one.
- The PyPI project page's links work. 35 of the README's 51 links were
relative, so on pypi.org they resolved under
pypi.org/project/evalshift/and 404ed; they now point at the files on GitHub. The project's Homepage and Documentation links now go to https://www.evalshift.dev and https://www.evalshift.dev/docs/cli instead of the GitHub repo.
evalshift reportandevalshift compareend with a one-line request to star the project on GitHub, with the repo link. It prints on interactive terminals only, so CI logs, the GitHub Action's output and anything piped stay free of it.
- Failure categories count examples, not scored rows.
tool_selectionstampsTOOL_SELECTION_DRIFTon both its conformance and its divergence row, so one example that picked another tool read "Different tools chosen (2 examples)" in the narrative and 2 in the report's top regression causes. Each label is now counted once per distinct example, asINVARIANT_VIOLATIONalready was. - The cached run narrative no longer outlives the figures it describes.
insights.jsonwas keyed on the run'sconfig_hash, which is fixed when the run is created, whileevaluateandanalyzere-readevalshift.yamlevery time: loosening a budget and re-runninganalyze+reportrendered a PASS verdict beside a cached "FAIL under the configured policy" paragraph. The key now also covers the narrative's facts (scores, analysis and the persisted decision), so any change to them regenerates it on the nextreport; a plain re-render still reuses it. Existing caches miss once and regenerate.
- A
semanticevaluator set toblocking: trueno longer fails runs whose every row stays withinmin_similarity. The source is pinned at 1.0, so any rewording is a small negative delta, and the paired significance test ran on those raw deltas: nine rows at 0.90–0.98 tested as acriticalregression and failed a run whose every budget passed. Rows withinmin_similaritynow enter the test as no change — the rule the budgets already used — and the comparison notes how many.delta_avg_scoreand the aggregates still show the raw drift. Re-analysing an older run can lower its semantic severities. - A fail caused by comparison severity alone now carries a
reasonnaming each comparison, so the report no longer shows "7 of 7 budgets within policy" under FAIL. The templated recommendation names the actual cause instead of always claiming a budget breach — a breached budget, including a slice's own (the 'security' slice overall regression rate budget was breached), or else the overall blocking regressions — and the summary's critical count reads the blocking comparisons. - Tool evaluators (including a top-level
trace_invariantsinherited by every suite) no longer attempt pairs that were offered no tools. On a tool-free suite they used to book every pair as unmeasured, demote a cleanpasstoconditional_pass, and blame "tool calls and no prose". They now skip those pairs, andevaluate(andcompare, under its verdict) warns that the evaluator checked nothing because no example offered tools. Coverage is written byevaluate, so an older run needsevaluatere-run to drop the stale coverage;analyzealone keeps it, and theconditional_passwith it. - The run-insights narrative no longer guesses why a run failed. The prompt listed each
budget's limit but not its result, so a FAIL on the latency ceiling alone was explained
as "failed to meet the budget for Trace-rule violations" — a budget that held. Every
budget, slice rows included, now enters the prompt with its status, the breached ones
are listed as
failed_budgets, and the decision'sreasontravels with them in plain words, withoutevalshift.yamlkeys. Only a breached budget may be named as the cause, and on a FAIL a summary or recommendation that blames a budget that held is rejected and retried, then replaced by the templated prose. Naming a held budget to clear it ("Cost increase stayed within its limit", "not Cost increase") is not blame, and neither is a figure "over the source". Narratives are cached on the run's config hash and model, not the CLI version: delete a run'sinsights.jsonand re-runreportto rewrite an existing one.
semanticno longer reads formatting as drift. Before embedding, both outputs drop a code fence wrapping the whole output, and JSON objects and arrays are re-serialized canonically (sorted keys). A source that fences pretty-printed JSON against a target returning the same JSON compact used to score ~0.73 and get flagged; it now scores 1.0. Semantic scores for structured outputs change on upgrade — expect higher similarity where the models only differed in layout.
capture sync --repairrebuilds theinputsandhistoryof cases an earlier sync built from stringified message blocks, keeping every other field and the case's input variable name, and re-downloads exactly the captures it needs fromcaptures.storeeven though they are promoted. It also rebuilds cases an earlier sync promoted with emptyinputs(OpenAIexclude_none, Responses, LangChain), which get no warning.capture syncandvalidatewarn when a suite still holds stringified cases; without--repair, sync leaves both kinds untouched. A repaired case that now duplicates another promoted case is named (repaired case <a> duplicates <b>) and nothing is deleted.evalshift initwrites thesemanticevaluator active for every provider. Anthropic and DeepSeek have no embeddings endpoint, so the scaffold borrows OpenAI's or Gemini's — whichever key you already have — andinit --ciwires that key as a second secret.compare,runandevaluatecheck every judge and embedding model's API key before the first call. An advisory evaluator without one is skipped with a line naming the env var to export; a blocking one stops the run, and so does a config whose every evaluator would be skipped. Asemanticthat is a gate only because a blockingtool_argumentsentry uses its embedder is reported as such, naming that entry. The skip is recorded instate.json(skipped_evaluators) and repeated in the verdict's recommendations, the HTML report and the bundle.doctorgains anevaluator keysrow.- When nothing gates the verdict, the recommendation now names the advisory
judge and says, from its own sample size, whether it is ready for
blocking: true(20 pairs per prompt) or how many examples it still needs. A judge that measured nothing (every call errored) is said to have measured nothing rather than told to collect more examples.
- A
semantic/llm_judgeentry withoutblocking: falseis a gate (the library default). If its model has no API key,compare,run,evaluateanddoctornow exit 1 before any call — previously the run completed with an unmeasured axis, landed oninconclusiveand passed--policy-gate, so CI that was green can turn red on upgrade. The baretext-embedding-3-smalldefault is checked for the first time. Export the key, or setblocking: falseto have it skipped with a warning. - Docs: the SDK's object-store variable is
EVALSHIFT_CAPTURE_STOREfrom SDK 0.6.0, matchingcaptures.storehere; SDK 0.5.0 reads the same value fromEVALSHIFT_SINK, which keeps working on newer SDKs too. - Every store message names the package to install instead of a pip extra:
pip install boto3,google-cloud-storage,azure-storage-blob azure-identity(both Azure packages in one command). The[s3]/[gcs]/[azure]extras still exist but are no longer documented.
capture syncandcapture promotenow read every wrapped provider's own message shape. Claude follow-up turns no longer get Python-repr strings ("[{'id': 'toolu_1', … 'type': 'tool_use'}]") in their history:tool_useblocks become the assistant'stool_calls,tool_resultblocks becometoolmessages, thinking is dropped, and a prompt-cachedsystemprompt or a text-block user turn becomes plain text. OpenAI Chat histories withcontent: nullno longer abort the whole sync with a traceback, and OpenAI Responses and LangChain captures recover theirinputsinstead of promoting them empty.- A turn whose recorded messages carry an image, a document, audio, a
server-tool result or an unknown block type is skipped (
capture promoterefuses it) instead of being promoted with that content stringified or lost;--allow-erroreddoes not apply. - An OpenAI
developermessage is now kept as the system prompt instead of being dropped: it merges with a leading system prompt (a Responsesinstructions=included), and a system ordevelopermessage after the first turn is dropped with a warning instead of aborting the sync; runcapture sync --forceto refresh cases promoted before. - A capture no valid case can be built from is skipped with the reason
(
capture promoterefuses it) instead of aborting the wholecapture syncwith a validation traceback;--allow-erroreddoes not apply. - A judge or embedding model with no API key no longer fails silently on every pair: it is skipped (advisory) or refused (blocking) before any call.
- The bare
text-embedding-3-smalldefault now resolves to OpenAI, so its key is checked. doctor'scaptures.storerow no longer fails the command — and with itcompareand every GitHub Action run — when the store's client library is not installed; it warns, naming the package to install. A run on a committed suite never reads the bucket;capture fetch,listandsync, which do, still exit 1.- A suspended account is named as such. When the server refuses a token with
account_suspended, the CLI prints its message — "Your account has been suspended. Contact support@evalshift.dev."evalshift loginwith a stored token for that account exits 1 with it instead of calling the token no longer valid and starting a browser sign-in, and push auto-create shows it without the token-access hint. Service account keys are not affected by a user's suspension.
captures.storeinevalshift.yaml: name the object store the capture SDK ships captures to —s3://<bucket>/<prefix>,gs://<bucket>/<prefix>oraz://<account>/<container>/<prefix>— andcapture syncandcapture listmirror new captures and toolset sidecars from it into.evalshift/before they run, with no flags. Only objects missing locally are downloaded (captures are immutable, toolsets content-addressed), captures already promoted are skipped socapture cleannever causes a re-download, and every file is written atomically. Newcapture fetchdoes only that step;--since 30m|24h|7d|<ISO>limits a fetch to recently written captures and--offlineskips it. Keys from a bucket are validated against the SDK's layout and anything else is ignored as malformed — including a suite directory containing:,/,\, NUL or.., or starting or ending with.or a space, and a key with a trailing newline — and no destination can land outside.evalshift/; folder-marker keys ending in/(the S3 console's "Create folder", ADLS directories) are skipped without being counted. A list or download failure exits 1 with a hint naming the provider's credential chain (aws sso login/AWS_PROFILE/ the CI OIDC role,gcloud auth application-default login,az login); a local write failure names the file and asks you to check disk space and permissions on its directory instead. Extras:evalshift[s3],[gcs],[azure](azure-storage-blob and azure-identity); a missing one exits 1 naming thepip installto run. Credentials come from each provider's default chain and are rejected in the URI: a value with@or?fails to load, naming the accepted forms. No rejected value is echoed back (it may be a pasted key or connection string); at most its scheme is named.capture list --jsonkeeps stdout pure JSON (the fetch line and warnings go to stderr).doctorreports whether the store is installed (fail, exit 1, when the extra is missing) and reachable (warnwhen it cannot be listed). Withoutcaptures.storenothing changes; asyncorlistwhoseevalshift.yamlcannot be read or does not validate skips the fetch, warning only when the file has a top-levelcaptures:key. Older CLIs reject thecaptures:key, so bump the CLI (and a pinned CI action'sevalshift-version) before adding it.evaluators.trace_invariants: hand-written rules over tool-call traces —forbidden,required,order,call_count(min_callsand/ormax_callsper tool; equal bounds = an exact count) andarguments(JSON Schema) — checked on both sides of every pair, on replayed traces or (traces: imported) on imported ones. A rule the target breaks counts whatever the source did. Itsapplies_tois enforced: a prompt outside the globs is never checked, andevaluatewarns, naming the entry and itsapplies_to, when the globs matched nothing in the run (so a typo is not a silent clean run). Atraces: importedentry on a run withouttraces.jsonlfailsevaluatewith an error naming that family.migration_policy.max_invariant_violations(default0, per-slice overridable): distinct examples on which the target broke a blocking trace rule, so an example that broke rules in two entries counts once. A breach fails the run even when the suite is too small for statistics, and a slice whose own budget breaches readsfailhowever few comparisons it has.analyzeadds a recommendation when the source broke the same rules the target did: review the rule with its owner rather than raising the budget. Pushed bundles'decision.policynow always carries the field; hosted EvalShift accepts and stores it since 2026-10-08 (it never re-evaluates it — a run that carries its own policy is answered with the CLI's decision).- The HTML report lists every broken trace rule in a "Trace rules broken"
panel, with its owner and whether the source broke it too — advisory
(
blocking: false) entries included, though only blocking ones count toward the budget; those rows are tagged "advisory".report.jsoncarries the same rows under a new top-levelinvariant_violationskey, each with the entry'sblockingflag. The "Overall, by evaluator" table tags every advisory (blocking: false) evaluator's row the same way and says what a trace-rule row measures, and the "Top regression causes" panel countsINVARIANT_VIOLATIONas the budget does — distinct examples with a blocking target violation — so the narrative never quotes a figure the verdict did not use. evalshift initscaffoldsmax_invariant_violations: 0in every--profilepolicy block, beside the other budgets. Older CLIs reject the field, so bump the CI action'sevalshift-versionpin to this release before committing a freshly scaffolded config.capture synckeeps a managed entry's hand-writtenevaluators.trace_invariantsblock when it regenerates the entry. The marker comment still says hand edits are overwritten; this block is the one exception.pushnow uploads each score row's one-lineexplanationand, fortrace_invariantsrows, the rules each side broke (violations.source/violations.target: rule id, type, tool, round, detail, and sample on repeated-sampling runs). Hosted EvalShift shows them on the example detail and on share links instead of only the violation count.rules_checked,ownerandfailure_categoriesare not repeated on the score row (they already ship inevaluator_config/decision). Requires a hosted service that accepts the two fields (hosted EvalShift was updated before this release); nothing else in the bundle changed.
- The report's per-prompt Executive summary — and the header's mean score Δ,
which averages its rows — now picks each prompt's worst severity with ties
broken toward the most negative effect size (the order the hero panel and
the narrative already used) instead of whichever evaluator
analyzelisted first, and skips advisory (blocking: false) evaluators while the prompt has a gating one. An advisory rule the target satisfied by calling no tools at all could otherwise read as the prompt's "+9% improved" above a failed verdict. - Upgrading moves the resume
config_hash: the new config fields are part of every config's canonical dump, so a run left in progress by an older CLI cannot be--resumed after upgrading (it fails with the usual config-hash mismatch). Start a fresh run; cached responses are reused.
evalshift pushprinted a traceback instead of the upgrade prompt when hosted EvalShift refused the run with HTTP 402 right after auto-creating the project (over the monthly run or concurrent-run limit, or a subscription that stopped paying). It now prints the server's message and the billing link and exits 1. A 402 while auto-creating the project — what an organization whose trial has ended gets — now shows the same prompt instead of an access-permission hint.evalshift pushprinted a traceback when the connection failed while auto-creating the project; it now prints the error and exits 1.
- The EvalShift repositories moved from the
babaliauskasGitHub account to theevalshiftorganization: https://github.com/evalshift/evalshift-cli,evalshift-sdkandevalshift-action. Links, package metadata and the workflowevalshift init --ciscaffolds now useevalshift/evalshift-action@v0. GitHub redirects the old names, so existing workflows keep working. - The CI pin check (
init,validate,doctor,capture sync) recognizesevalshift/evalshift-actionsteps as well as the oldbabaliauskas/evalshift-actionname, matching owner and repository names case-insensitively as GitHub does. - Docs and the
init --ciworkflow comment describe hosted EvalShift's current plans: a 30-day Pro trial for every new account, then Pro per organization (the hosted Free and Team plans are gone). Local runs stay free and unlimited.
- Top regressions in the HTML report now read as expandable: the worst one in each section starts open, each row has a boxed caret and a "Show details" / "Hide details" hint, and the header reacts on hover and shows a keyboard focus ring. Printing expands every regression in Chromium-based browsers.
Shipped as a minor again. The slices removal below is breaking by the
letter of SemVer — a config that still sets the key stops loading — but the
field never had any effect: analyze has always built one slice per
distinct example tag plus all, so name, filter and applies_to renamed,
filtered or scoped nothing. A major would announce a migration that, for
anyone who never wrote slices:, does not exist; those who did get a
load-time error naming the key and the fix, the same way thresholds was
handled in 1.1.0.
- DeepSeek is a supported provider.
deepseek-flashanddeepseek-v4-proare in the model registry; baredeepseek-*ids (as recorded by a capture of an app callingapi.deepseek.comthrough the OpenAI client) resolve todeepseek/…;DEEPSEEK_API_KEYis checked before a run and shown byevalshift doctor; tool-call evals parse DeepSeek responses; a DeepSeek judge grading a DeepSeek arm gets the judge-family warning; andevalshift init --provider deepseekscaffolds a DeepSeek project. DeepSeek's default thinking mode ignorestemperature, so DeepSeek arms and a DeepSeek judge carry the report's non-determinism banner, and every assistant turn replayed from the recording (tool rounds and chat history) is sent with a placeholderreasoning_content: DeepSeek requires it on tool requests and ignores it otherwise.
-
evalshift doctor'sevalshift.yamlrow now ends a failure with "— runevalshift validatefor details". The row has room for the summary only ("1 schema problem found"), which names neither the offending key nor the fix;validateprints both. -
Repeat runs of agent suites are now served from the response cache. Every example that offers tools used to bypass the cache, a leftover from v0.2 when a cache entry could not hold a parsed tool trace, so each
runof an agent suite paid for every call again, one per replayed round. Each replayed round is now its own entry. It is keyed on the canonical model, the prompt and inputs, the exact message list that round sends (history, current turn, and the recorded rounds and fixture results fed back), the tool list exactly as sent and in order (includingstrict),generation_config(sotool_choiceandparallel_tool_calls), the effective temperature andmax_tokens, the round index and the sample index. A hit restores the parsed trace, tokens, cost, latency and finish reason, so theraw.jsonlrow is identical to the live one apart fromcached, which is true only when every round hit, and the newcached_roundscount. A row with any round served from the cache carries latency from an earlier run, so it stays out of the report's live latency figures and its latency delta is marked not comparable, inreport.jsonand in the bundle alike. Errors are never cached: the next run re-sends a failed round and serves the rounds before it from the cache. Truncated responses are cached and stay flagged, anddefaults.cache: falsestill sends everything live. Existing~/.evalshift/cache.dbfiles keep working: the newtrace_jsoncolumn is added in place on open, and every cached text response still hits.
-
The top-level
slices:key is gone fromevalshift.yaml, and its removal is breaking. The block (name,filter,applies_to) was validated and recorded in the run bundle, but analysis never read it: slices have always come from exampletags, one per distinct tag plusall, so none of its fields renamed, filtered or scoped anything, and a run reports the same slices without it. Migration: delete the block. Per-slice budgets, the one thing it looked like it configured, go undermigration_policy.slices, keyed by tag.A config that still sets
slices:now fails to load instead of being quietly ignored, the same waythresholdshas since 1.1.0, naming what happened and the fix:`slices` was removed: it never had any effect. Slices come from example `tags` automatically (one per distinct tag, plus `all`). Delete it from evalshift.yaml; per-slice budgets go under migration_policy.slices, keyed by tag.evalshift validateprints it;evalshift doctorfails its config row and points atvalidate.version:stays1, under the same config version policy. The two shipped example configs that set the block no longer do, andtests/unit/test_docs_currency.pynow fails if any doc or example shows a top-levelslices:orthresholds:key.Hosted baselines are unaffected for every config that never set the key (or set
slices: []): the bundle'sevaluator_configstill carries"slices": [], soeval_config_hashis byte-identical to what earlier CLIs computed and existing baselines keep matching. Deleting a non-empty block does change that hash, so runs pushed afterwards are not comparable to baselines pushed before until the base branch pushes a run with the edited config.
-
The scaffolded CI workflow's key guidance (
evalshift init --ci) named onlyrun:create+run:read. That workflow defaults tofail-on: policy, which reads the hosted policy-check endpoint (policy:read); a key scoped to onlyrun:create+run:readgot a 403 there and the action silently fell back tofail-on: regression. The scaffold,DOCS.md,llms-full.txt,docs/github-action.md, anddocs/hosted.mdnow namerun:create+run:read+policy:readfor the CI key. -
A run whose target was served entirely from the cache while the source ran live showed a -100% latency change in the HTML report header and in the run insights. Latency averages cover only calls measured live on this run, so a role with none of them averages 0, which means unmeasured, not instant. Both now say the latency change is not comparable unless both roles measured some latency live.
-
Two
evalshiftprocesses opening the response cache at the same moment (parallel CI jobs, or arunbeside anevaluate) could crash one of them withtable cached_calls already existswhen the cache file was new. Both had checked for the table, and the slower one then tried to create it too. Opening the cache now treats that error, and its migration counterpartduplicate column name, as "another process already did it" and carries on; any other schema error is still raised. -
Under SQLAlchemy 2.1, which fresh installs resolve (
sqlalchemy>=2.0), aCacheStoreopened on an in-memory SQLite database could silently lose concurrent writes. The default on-disk cache used by CLI runs was not affected. In-memory databases get SQLAlchemy's single-connectionStaticPool, so concurrent sessions shared one transaction, and one session's rollback-on-return could discard another's uncommitted write. SQLAlchemy 2.1's aiosqlite rework (sqlalchemy#10415) made this happen routinely, so some cached writes were missing on the next lookup. Two orchestrator cache tests failed on every fresh checkout, and the embeddings-cache test failed intermittently.CacheStorenow runs its sessions one at a time on a single-connection pool. This works the same on SQLAlchemy 2.0 and 2.1. -
capture syncpriced calls recorded under a bare id at $0 when LiteLLM keys the model under both spellings but can only price the provider-prefixed one (DeepSeek). The price lookup now tries the provider-prefixed id first. -
README.md, DOCS.md, llms-full.txt, four
docs/pages, and AGENTS.md advertisedevalshift all --pushas the command to run.allhas been a hidden alias forcomparesince 1.0.0 — it still works, and always will — but it is hidden fromevalshift --help, so the docs were teaching a name the CLI does not advertise. Every advertised example now readsevalshift compare --push; nothing about the CLI changed.AGENTS.mdis now also guarded bytests/unit/test_docs_currency.py. -
README.md, docs/index.md, docs/faq.md, docs/hosted.md, and docs/getting-started.md described the LLM providers a run can call as "Anthropic, OpenAI, Google" (or, in docs/getting-started.md, the Oxford-comma "Anthropic, OpenAI, and Google") — read as a compatibility list, when every call actually dispatches through LiteLLM and those three are just the entries with a curated pricing table. A separate FAQ answer in the same
docs/faq.md("what models does EvalShift support?") already used the correct framing ("Anything LiteLLM supports."); all five sites now name LiteLLM as the boundary too, with Anthropic, OpenAI, and Google called out as the ones that get curated pricing and capability data. Thetest_docs_currency.pyabsence check now matches the Oxford-comma phrasing too, not just the plain three-item list. Nothing about the CLI changed. -
DOCS.md, llms-full.txt, and docs/configuration.md quoted the scaffolded
replayprompt's content as"{{input}}"— the escaped form that appears literally ininit.py'sstr.formattemplate, not what ends up on disk. Because the doubled brace is a format escape, theevalshift.yamlthatevalshift initactually writes contains"{input}", astest_init.pyalready asserted. All three sites now quote"{input}", matching the other places in DOCS.md and llms-full.txt that already had it right. Nothing about the CLI changed. -
README.md claimed "the test suite covers 92% of the source," a literal nothing checked; the suite now measures 94%, and nothing enforced either number. Reworded the Status section to say "CI enforces a 90% coverage floor on the source" instead of quoting a point-in-time percentage, and added
--cov-fail-under=90to the three CI entry points (.github/workflows/ci.yml,Makefile'stesttarget, and.pre-commit-config.yaml's pre-push mirror) rather than to[tool.coverage.report], so a targeted local run such aspytest tests/unit/test_init.pyis not failed by a floor meant for the whole source tree. The floor sits below the measured 94% on purpose, so a real regression fails CI while an honest refactor does not. -
An audit of the docs against the code found them wrong in a few dozen places, and all of them now say what the CLI does. Nothing about CLI behaviour changed. The one that matters most concerns slices: the docs said a top-level
slices:block picks examples by tag, names the slice, and scopes it to prompts. It did none of that. The block was validated and recorded in the run bundle, but analysis never read it. Slices come from exampletags, one per distinct tag plusall, and per-slice budgets are keyed by tag undermigration_policy.slices. The docs now say so, and the block itself is removed in this release (see Removed above). Imported agent traces (traces import) stay local; the bundle carries only the replay's own tool-call trace, without tool results ormodel_callevents. The response cache served only tool-less examples, so everyrunof an agent suite was live and full price (it now serves them too; see Changed).--resumehashes the suite's path, not its contents.push <run-id>uploads an existing bundle as-is instead of rebuilding it, andbundleneeds a git SHA.--policy-gatealso fails when nomigration_policyis configured. Theinitprofile table had the wrongmodel-upgradenumbers and no tool-divergence column. The multi-turn suite example failed to load because it had notools. The failure-label list was missingTOOL_GROUND_TRUTH_MISS. Upstream model-call failures and evaluator failures are handled the same way, as errored rows excluded from the statistics. The GitHub Action docs gainedrequire-policyand the other missing inputs.record_model_callexamples now pass the requiredtools=. DOCS.md's header said version 1.0.1; a new check intests/unit/test_docs_currency.pykeeps the version in DOCS.md and llms-full.txt equal to the package's.
Shipped as a minor deliberately. The thresholds removal below is breaking by
the letter of SemVer — a config that sets the key stops loading — but the key
was free-form, gated nothing, and travelled no further than a project-settings
blob nobody read. A major would have signalled a migration that, for anyone who
never wrote thresholds:, does not exist. Those who did get an error naming
the key and the fix, which is the whole migration.
-
migration_decision.jsonand the bundle now carry the resolvedmigration_policythe verdict was computed under, asdecision.policy. The hosted server previously gated pull requests on a separate, web-edited policy that nobody configures, silently turning the PR gate off; the CLI's own budgets riding inside the bundle is what lets the server check a migration against the same numbers the CLI's verdict used.nullwhen nomigration_policyis configured, and on amigration_decision.jsonwritten by an older CLI. This release needs a hosted server that acceptsdecision.policy: local pre-flight validation passes for a policy-carrying bundle, so pushing a gated run at a host that has not deployed that change yet uploads in full and only then fails at finalize — self-hosted and staging deployments behindEVALSHIFT_HOSTshould upgrade the server first. -
evalshift push— andevalshift compare --push, which prints through the same console — now warn when the run being pushed carries no migration policy: unless the project still has an old web-app policy for the server to fall back on, the hosted gate reportsinconclusiveand the pull request it belongs to is never blocked, which is otherwise indistinguishable from a gate that passed. Projects whose only policy was configured in the web app get that policy printed back as themigration_policy:block to paste intoevalshift.yaml— printed only while the yaml has no policy of its own, and validated first, so what is shown is config the CLI accepts.
-
thresholdsis gone fromevalshift.yaml, and its removal is breaking. The key was free-form and gated nothing: the migration verdict has always come frommigration_policy, andthresholdsonly ever travelled to the hosted API as project settings. Keeping it meant a config could carry numbers that read like a gate and decided nothing — the same confusiondecision.policyabove exists to end. Nothing replaces it. Migration: delete the block, and express any gate you meant by it as amigration_policybudget.The break has two axes. A config that still sets
thresholds:now fails to load instead of being quietly ignored, naming what happened and the fix:`thresholds` was removed: it was free-form and gated nothing. Delete it from evalshift.yaml; migration_policy is the single source of truth for gating.Dropping the key silently would have preserved the very impression the removal is meant to end — a config that looks gated and is not. The second axis:
push_bundle()inevalshift_cli.hosted.pushno longer takes athresholdskeyword.version:stays1. The config version policy now says so explicitly: the literal marks a config that is still valid but would be read with the wrong meaning, and a removal that fails the load while naming the key is the opposite of that. Bumping it would have forced an edit on every config, including the majority that never setthresholds. -
The hosted traffic that carried it goes with it:
pushno longer sendsthresholdswhen it uploads a run or creates a project, the CLI ignores the server'scanonical_thresholdsresponse field, and the "local thresholds differ from the hosted canonical thresholds" warning is gone along with the setting it compared against.
-
evalshift init --cinow scaffolds the eval job withsuite-name: ${{ matrix.suite }}instead ofsuite: .evalshift/suites/<name>/golden.jsonl. The path form loads the same rows but resolves no suite name, so the suite was scored with the top-levelevaluators:and its own block undersuites:— the tool evaluatorscapture syncwrites for a tool-calling suite — never loaded. On a capture-first project whose top level issemantic+llm_judge, that scored no rows at all and CI failed atanalyzewithscores.jsonl is empty, naming nothing that pointed at the selection. Regenerate the workflow (evalshift init --ci --force) or change the input by hand; the step needsbabaliauskas/evalshift-action@v0at a release that offerssuite-name, and anevalshift-versionpin of 0.14.0 or newer. -
evalshift loginreuses a working stored token instead of minting a new one. Every browser approval mints a fresh personal token on the server and the CLI holds only one at a time, so re-runningloginstranded the previous token — two "EvalShift CLI " tokens after two logins.loginnow verifies the stored credential for the requested host first and reports "already logged in" when it still works; a 401/403 falls through to the browser flow, other failures are reported as-is, and an explicit--tokenstill always saves.
-
First stable release. EvalShift now follows Semantic Versioning against a written contract:
evalshift.yaml, command names and flags, exit codes, the documented artifact fields, and the run bundle are public surface, and a breaking change to any of them requires a major release. What is explicitly not public — the internals of.evalshift/, theevalshift_cliPython package, report markup, console wording — is listed too, so the boundary is checkable rather than implied. See Compatibility and stability. The config schema keeps its own evolution rule, unchanged:version:bumps only for breaking schema changes, additive fields ride the CLI version. -
evalshift allis nowevalshift compare. The old name promised something the command never did: it runs all pipeline stages (doctor → run → evaluate → analyze → report) against one suite, so a project with several wired suites typed what looked like a complete command and got an error.comparesays what it does — compare two models on a suite — and nobody expects a comparison to fan out.allkeeps working and is not scheduled for removal. It is hidden from--helpand prints a one-line notice on stderr (so CI parsing stdout is unaffected) pointing at the new name. Both names bind the same function, so there is no second code path. The alias is permanent becauseevalshift initwritesEVALSHIFT.mdinto user repos telling agents to runevalshift all, and that file is never regenerated.Scripts, CI jobs, and scaffolded projects need no change. Docs,
--help,llms-full.txt, theEVALSHIFT.mdtemplate, and theinit/capture syncnext-step hints all teachcomparenow. -
Suite selection explains itself when it cannot pick one.
runandcompareused to fail with a single line whenevalshift.yamlwired more than one suite. They now print a framed panel that names the wired suites and lists one ready-to-run command per suite, rebuilt from the flags actually typed — soevalshift compare --yes --pushis answered withevalshift compare --yes --push --suite-name <suite>, and a stale--suite-nameis stripped rather than duplicated. Invoked under the legacyallname, the panel also spells out thatallmeant all stages, not all suites. An unknown--suite-namegets the same treatment, andbundle/pushshare it. Behaviour is unchanged: one suite per invocation, auto-selected when exactly one is wired. -
Docs no longer show a bare pipeline invocation in the hosted-push walkthroughs; every example names a suite, and
docs/getting-started.mdshows the shell loop for covering every suite.
- License: AGPL-3.0-or-later → Apache-2.0. The CLI is a client for a
closed-source hosted service, so the copyleft protected nothing that mattered
while tripping the blanket AGPL bans and dependency/SBOM scanners many
companies run over their CI environments. Apache-2.0 drops that friction and
adds an explicit patent grant. Nothing about how you use the tool changes,
and the capture SDK (
evalshift-sdk) was already MIT. Releases0.4.0through0.14.1remain available under AGPL-3.0-or-later.
capture syncnow pairs a history tool result that was recorded without atool_call_idwith the preceding assistant turn's next unanswered tool call, in order, instead of assigning it a positional_pos<N>id. Gemini puts no ids on function responses, so every promoted Gemini conversation with a tool round carried an id nothing could match and LiteLLM rejected the replay of every later turn on both models (Missing corresponding tool call for tool response message) before it reached the provider. Re-runevalshift capture syncto regenerate affected suites. A result with no preceding call to answer still gets_pos<N>with a warning.- LiteLLM's "Give Feedback / Get Help" banner, which the library prints on every failed call outside any logger, no longer floods the pipeline output.
-
defaults.samples_per_example(default1, max20): send every(prompt, example)to each model N times. Each sample is its own live call (raw.jsonlrows carrysample_index; the cache key includes it, so a single-sample run keeps every existing cache entry and resume skips per sample).evaluatescores source sample i against target sample i and folds the samples of one example into onescores.jsonlrow: the meansource_score/target_score/deltaover the samples that scored, the per-sample lists and the populationdelta_varianceundermetadata.samples, and the explanation prefixedmean of k samples. The paired tests still run over examples, sonis unchanged. The report shows anN samples per examplepill,report.jsoncarriessamples_per_example, the non-determinism banner suggests the setting on a single-sample run, and example rows (report, bundle, insights,inspect) show sample 0. The cost estimate and pre-flight call count multiply by N. -
Teacher-forced multi-round replay.
capture promote/capture sync --rounds allnow carry the recorded tool results on the promoted example astool_result_fixtures— one inner list per covered round, positionally aligned withexpected_tool_rounds, each entry{tool_name, result, error}— pairing each round's calls with that round'stool_resultevents bycall_idfirst and by tool name within the round second.evalshift runthen replays such an example round by round: round k is sent the prompt (and anyhistoryprefix) followed by the recorded rounds1..k-1as assistant tool calls andtoolresults, so source, target and the recording all see identical context; the candidate's own calls are never fed back. The replay covers every round the fixtures cover plus the round after it, which — when every tool round is covered — is the answer round, where the recorded agent called nothing and produced its final text. Fixture coverage stops at the first round with a call that has no recorded result, with a warning naming the rounds the replay will cover. Oneraw.jsonlrow per example per model as before: tokens, cost and latency summed,textthe last round's answer, theToolTracecarryinground_countand around_indexon every call. A model error in round k fails the example asround k/n: ….tool_selection,tool_argumentsandtool_trace_structurescore each round against its own ground truth (conformance againstexpected_tool_rounds[k], "called nothing" for the answer round; divergence and argument pairing within a round) and record the mean over replayed rounds with per-round detail undermetadata.rounds, somax_tool_divergencecounts an example as diverged when any round diverged. The HTML report andreport.jsonshow one line per round in the tools column and prefix trace-diff items withRound k:; bundle trace events now carry the realround. The cost pre-flight counts one call per replayed round. Suites without the field (every suite written before it, and every--rounds firstpromotion) replay single-shot exactly as before. -
cache_keyaccepts around_index(hashed only when set), so tool-call caching can land later without a cache migration; the tool path still bypasses the cache. -
Runs now record which generation parameters the models cannot honour, instead of
drop_params: Truemaking them vanish. A promoted capture can pin the generation config its original call used (temperature,top_p,response_format/response_mime_type/response_schema,max_tokens/max_output_tokens,tool_choice/tool_config,parallel_tool_calls), and the replay sends it — but LiteLLM'sdrop_paramslets a model that never accepted one of those answer anyway, minus the constraint, so the arm measured a model change plus a missing constraint with nothing saying so. At run start EvalShift now asks LiteLLM (models.capabilities.unsupported_params, the generalisation of the existinghonors_temperatureprobe) which of the parameters the suite actually recorded each arm supports, mapping provider spellings to their OpenAI names first. Anything a model positively lacks lands instate.jsonunderdropped_params(model id → [param, …]), is logged once per (model, parameter) atWARNINGrather than per call, reachesreport.jsonasdropped_params, and renders as a Constraints not honoured banner beside the sampling banner in the HTML report. Uncertainty reads as "supported" — an exception, aNone, or an empty answer records nothing — on the same reasoning as the sampling probe: a false banner on every report costs more than one missed warning.temperaturestays withnon_deterministic_models, which owns its own banner and probes unconditionally. Calls are unaffected:drop_paramsis still on, so nothing that used to succeed now fails. Alongside the probe, a small hard-coded table (models.capabilities._KNOWN_LITELLM_GAPS) covers what the probe cannot see: parameters LiteLLM reports as supported and then discards while building the provider's request body. Verified against litellm 1.100.0, that is two Gemini cases —parallel_tool_calls(filtered out againstGenerationConfig's fields) and a tool'sstrictflag (dropped by_map_function, since Gemini function declarations have no strict mode) — and both now land indropped_paramsfor a Gemini arm whose suite recorded them, where previously they only produced a per-dispatch log line. The tool flag is recorded under the pseudo-parameter nametools.strict, because it is a field on thetoolsarray rather than a generation parameter. Probe and table merge into one sorted list per model. The two dispatch-time warnings inmodels/client.pyare gone, since the run-start record now says the same thing and reaches the report and the policy; atool_choiceon a tool-less example is still stripped with a warning at dispatch and is deliberately not recorded as a dropped parameter — it is a fact about the suite, not about either target. -
migration_policy.fail_on_dropped_params(bool, defaultfalse) turns that record into a gate: when set anddropped_paramsis non-empty, the verdict isfailwith a reason naming each model and parameter, whatever the scores said. For suites where the constraint is the contract — captures that pinnedresponse_formatmeasure nothing useful against a target that will not produce structured output. Top-level only (a model either accepts a parameter or does not, which no slice can vary), and runs recorded beforedropped_paramsexisted are never failed by it. -
Trace models accept
requested_tool_callson amodel_callevent — the tool calls the model asked for in its response, as{name, arguments, call_id}entries. It sits alongside the two notions that already existed and is none of them:toolset_ref/tools_offeredis what was offered to the model, thetool_call/tool_resultevents are what the app executed, and this is what was requested.null(the default) means the trace predates the field;[]means the model requested no tools. The capture reader gates on the schema major only, so a capture written at the SDK's new2.1.0schema loads unchanged. Trace models areextra="forbid", so the CLI has to accept the field before any SDK writes it. -
capture promote/capture syncnow use those model-requested calls as the tool-call ground truth whenever a capture carries them, and record which yardstick a case used aspromotion_source("requested"|"executed", default"executed") on the promoted-case file. The executedtool_callevents have already passed through the application — its filtering, retries, re-ordering, and its own function signatures — so they show what the app did, while a golden case has to state what a model should produce. On the requested path eachmodel_callis one round (rounds that requested nothing are dropped, exactly as tool-less executed rounds are) and arguments are carried verbatim: wrapper unwrapping never runs on them, because nothing stands between the model and its own requested call. Everymodel_callin a run has to carry the field for the capture to be scored against requested calls — pass[]for a round in which the model requested no tools, sincenullmeans not recorded rather than nothing requested. Fallback to the executed calls is silent for a capture that predates the field, and warns for one where only somemodel_callevents carry it (the whole capture falls back rather than mixing yardsticks). When requested and executed calls disagree, the requested ones win and promotion warns, naming the tools on both sides. -
Replay now carries the tool-choice constraints production used, instead of debug-logging and dropping them. A recorded
tool_choicereaches the target in whichever of the three spellings the capture holds — an OpenAI string ("auto"/"none"/"required") or object, an Anthropic object ({"type": "auto"|"any"|"tool", "name"?, "disable_parallel_tool_use"?}), or Gemini'stool_config(function_calling_config.mode, withallowed_function_names) — all normalised to one OpenAI-style intent plus aparallel_tool_callsbool, which LiteLLM then maps onto each provider's own shape (Anthropic'stool_choiceobject carryingdisable_parallel_tool_use, Gemini'stoolConfig). Normalising rather than passing through is what lets a capture recorded against one provider replay meaningfully against a target on another. An Anthropicdisable_parallel_tool_use: truebecomesparallel_tool_calls: false; an explicit top-levelparallel_tool_callswins over the inferred one. -
ToolSpecgainedstrict, so a toolset sidecar or inlinetoolsentry carryingstrict: true(top-level in the canonical/Anthropic shape,function.strictin the OpenAI shape) is replayed instead of being rejected as an unknown key. Both serialisers emit it only when set, so non-strict tools serialise byte-identically to before and toolset fingerprints are unchanged. -
Generation-config keys the runner cannot translate are now logged at warning rather than debug — once per distinct key set, so a whole-suite replay says it once. Constraints a target provider genuinely cannot express are named rather than dropped in silence: a Gemini target warns for
parallel_tool_calls(generateContenthas no such switch) and for a tool'sstrictflag (Gemini function declarations have no strict mode), once per model and key. Atool_choicethat reaches an example with no toolset is dropped with a warning — there is nothing to constrain. -
doctorrowevalshift-sdk: reports the SDK version theevalshiftimport name resolves to in this environment;warn(never a failure) when the SDK is missing, fails to import, or is shadowed by an older CLI's leftover files or a localevalshift/directory. -
Judge-family warning (
evalshift_cli.models.family): when anllm_judgejudge_modelresolves to the same provider asdefaults.source_modelortarget_model,doctorprints a warn-leveljudge familyrow (one per distinct judge;ok"from a third family" when none overlaps; no row when either arm is unset) andvalidateprints the same line after its success line — LLM judges prefer their own relatives' output (self-preference bias), so verdicts lean toward that arm. Advisory, never a failure:initscaffolds a same-provider judge on purpose. The report repeats the note as a third banner, only for judges that actually contributedscores.jsonlrows, andreport.jsoncarries it asjudge_family_overlap. Providerother(an id the registry cannot place) never matches. -
CI pin-drift check (
evalshift_cli.utils.ci_pin):capture sync,init(without--ci),doctor(newci pinrow), andvalidatenow parse.github/workflows/*.ymlforbabaliauskas/evalshift-actionsteps and warn when theevalshift-versionpin is older than the local CLI (stale), absent (unpinned— the action default may lag), or newer than the local CLI (ahead), printing the exactevalshift-version: "<v>"line to set. Advisory only: the CLI never edits a workflow and exit codes are unchanged. Rationale:extra="forbid"config means the reader in CI must be at least as new as the writer locally. -
packaging>=23is now a declared dependency (version comparison). -
examples/capture-first/— the first example to use the managedsuites:block. It checks in the whole capture-first flow: an SDK-instrumented agent, the three captures and the toolset sidecar it recorded, the promoted cases andgolden.jsonlthatevalshift capture syncwrote, and the uneditedevalshift.yamlfrominitwith sync's derivedtool_selection/tool_argumentsblock filled in. Its own.gitignoreshows how a project commits.evalshift/suites/and.evalshift/toolsets/while keeping runs and the cache ignored. -
Promoted case files now record what the captured run cost:
cost_usd(the run'smodel_callevents summed) andcost_source. The SDK never prices anything — amodel_call'scost_usdis0.0unless the app's own instrumentation set it, and the provider client wrappers record tokens but no cost by design — socapture promote/capture syncnow price each event that recorded tokens but no cost from litellm's price table for its ownmodel_id, tagging the casecost_source: "estimated". A recorded non-zero cost is kept as recorded ("recorded"), never re-estimated. A model litellm does not price — local / self-hosted, the normal case for open-source models — stays at0.0with no tag and no warning; the pricer is never called for it, socapture syncneither prints litellm's provider banner nor opens a socket to a local Ollama daemon. Existing case files parse unchanged (both fields default), and thegolden.jsonlexample never carries the figure.
- LiteLLM warnings no longer print in the middle of the
evalshift allpipeline block (and again in the deferred-warnings section) on litellm >= 1.100. That release routes records below WARNING tosys.stdoutby re-pointing its handler's stream per record; because onlysys.stderrandsys.__stderr__counted as console streams, a single INFO record left the handler unrecognised anddeferred_console_warnings()stopped detaching it.sys.stdout/sys.__stdout__now count too.
--rounds allno longer flattens every recorded round intoexpected_tools.expected_toolsis nowexpected_tool_rounds[0]under both settings, and--rounds allmeans teacher-forced multi-round replay instead (see Added). The flattened list was only ever right for comparing against an externally produced multi-round trace, which theagent_traceevaluator does from imported traces.--tool-countunder--rounds allpins the total over the rounds the replay reaches rather than over every recorded round.- Breaking (packaging): the CLI's import package is now
evalshift_cli. The distribution (evalshift) and theevalshiftcommand are unchanged. The import nameevalshiftbelongs to the capture SDK, which the CLI now declares as a dependency (evalshift-sdk>=0.3.0), so both install into one environment andpip install evalshiftbrings the SDK with it — the two-virtualenv rule is gone from every install page. What a user can notice:python -m evalshiftis nowpython -m evalshift_cli, and scripts that imported CLI internals (from evalshift.models.client import …) must import fromevalshift_cli. No shim is possible — shipping anyevalshift/file would recreate the collision — so this ships as a minor bump (0.14.0). - Docs: the capture guides (
README.md,DOCS.md,docs/sdk.md,docs/getting-started.md,llms-full.txt) now cover the SDK 0.4.0 provider client wrappers (wrap_openai/wrap_anthropic/wrap_genai), therequested_tool_callspromotion path andcapture sync --rounds, and no longer tell users to installevalshift-sdkin a separate venv — the CLI depends on it since theevalshift_clirename. The capture-first example README explains why its cases arepromotion_source: executed. - Documented the config version policy:
version: 1bumps only for breaking changes; additive fields ride on the CLI version and the CI pin check is the mechanism that keeps CI's reader at least as new as the local writer.
evalshift init --cinow scaffolds a production-shaped GitHub Actions workflow instead of a single-suite example: dynamic suite discovery under.evalshift/suites/(a project with no suites yet skips green), one matrix job per suite, a singleevalshift gatejoin check for branch protection,fail-on: policy(the action's real default),evalshift-versionpinned to the scaffolding CLI, a provider API key matching--provider, PR-only run cancellation so base-branch baselines survive, and the full setup checklist documented in the generated file's header.
Initial public release.