Result formats
evalship reads six result formats. Each result becomes the same eval record: a suite, a key, pass or fail, and optionally a score, threshold, duration, cost, tokens, input, output, failure message, file, targets, url and the AI calls behind the reply.
| Format | Typical source | File |
|---|---|---|
| JUnit XML | pytest, Vitest, Jest (jest-junit), RSpec (rspec_junit_formatter), go-junit-report, Maven, Gradle, PHPUnit (--log-junit), cargo-nextest |
.xml |
| promptfoo JSON | promptfoo eval -o results.json |
.json |
| pytest-json-report JSON | pytest --json-report (pytest-json-report) |
.json |
| Vitest or Jest JSON | vitest --reporter=json --outputFile=results.json, jest --json --outputFile=results.json |
.json |
| Inspect AI log | inspect eval, as written to logs/ |
.eval, or .json with --log-format json |
| evalship JSON | Hand-rolled evals, or any framework you can write JSON from | .json |
Auto-detection
evalship detects the format from the content, not the file name:
- Content that starts with
<is JUnit XML. - A zip file is an Inspect AI
.evallog. - A JSON object with an
evalsarray is evalship JSON. - A JSON object with
evalIdor aresults.resultsarray is promptfoo JSON. - A JSON object with a
testsarray whose entries have anodeidis pytest-json-report JSON. - A JSON object with a
testResultsarray, withnumTotalTestsor withassertionResultsin its entries, is Vitest or Jest JSON. - A JSON object with an
evalobject and aversionis an Inspect AI JSON log.
Anything else is skipped, so other .xml, .json and .eval files in the same artifact do no harm. A leading byte order mark is ignored.
JUnit XML
Each <testcase> is one eval. Evals with a <skipped> child are ignored.
| Field | Where it comes from |
|---|---|
| key | classname and name joined with a dot, or name alone when there is no classname |
| name | name |
| suite | The enclosing <testsuite> name. When that name is generic (pytest, rspec, vitest..., jest..., mocha..., root, default, test, tests or empty) or looks like a file path, the suite is the stem of the eval's file instead. |
| file | file on the testcase, else file on the testsuite, else the testsuite name when it is a path, else classname with dots replaced by slashes |
| passed | false when the testcase has a <failure> or <error> child, else true |
| failure | The message attribute and the text of <failure> or <error> |
| duration_ms | time (seconds) times 1000 |
| output | The output property, else <system-out> |
The stem of a file is its base name up to the first dot, without a test_, eval_ or evals_ prefix and without a _test, _spec, _eval or _evals suffix. tests/evals/test_support_bot.py has the stem support_bot.
For example, pytest writes a testsuite named pytest and this testcase:
<testcase classname="tests.evals.test_support_bot" name="test_refund_limit"
file="tests/evals/test_support_bot.py" time="1.84">
evalship reads it as suite support_bot, key tests.evals.test_support_bot.test_refund_limit.
Properties
A <properties> block inside a <testcase> adds data to the eval:
| Property | Meaning |
|---|---|
score |
Number. Scores between 0 and 1 work best, because tolerance is an absolute difference. |
threshold |
Number. Shown next to failing scores. |
targets |
Comma-separated code the eval exercises, as path or path#symbol. See Untested changes. |
input |
The eval input. |
output |
The model output. |
cost_usd |
Cost of the eval in US dollars. |
tokens |
Tokens used. |
url |
Where the eval lives in another tool, such as its trace. See url. |
| Any other name | Stored in the eval's metadata. |
In JUnit, a score below its threshold does not fail an eval by itself; pass or fail always comes from <failure> and <error>.
<testcase classname="tests.evals.test_support_bot" name="test_refund_limit"
file="tests/evals/test_support_bot.py" time="1.84">
<properties>
<property name="score" value="0.41"/>
<property name="threshold" value="0.7"/>
<property name="targets" value="app/support_bot.py#answer,prompts/support_system.md"/>
</properties>
<failure message="assert 0.41 >= 0.7">...</failure>
</testcase>
pytest: record_property
pytest's record_property fixture writes properties into the XML:
def test_refund_limit(record_property):
question = "Can I get a refund of $500?"
reply = answer(question)
score = judge_refund_policy(reply)
record_property("score", score)
record_property("threshold", 0.7)
record_property("targets", "app/support_bot.py#answer")
record_property("input", question)
record_property("output", reply)
assert score >= 0.7
Run pytest with -o junit_family=xunit1:
pytest tests/evals --junitxml=eval-results.xml -o junit_family=xunit1
pytest's default family, xunit2, doesn't write per-test properties. To set it once, add this to pyproject.toml:
[tool.pytest.ini_options]
junit_family = "xunit1"
promptfoo JSON
Each test, prompt and provider combination is one eval.
| Field | Where it comes from |
|---|---|
| suite | description at the top of your promptfoo config, else promptfoo |
| key | The test's description. When the eval has more than one prompt, | <prompt label> is appended; when it has more than one provider, | <provider> is appended. |
| name | The test's description |
| passed | success, else gradingResult.pass |
| score | score, else gradingResult.score |
| failure | gradingResult.reason, else error |
| input | The test's vars |
| output | response.output |
| duration_ms | latencyMs |
| cost_usd | cost, else response.cost |
| tokens | response.tokenUsage.total |
| targets | The prompt file, when the prompt label is a file path such as file://prompts/support.txt |
| url | shareableUrl, which promptfoo eval --share sets |
| metadata | The provider, the assertion types, and judge: llm when every assertion is model-graded, deterministic when any is not |
A test without a description gets the key test <8 hex characters>, derived from its vars. Changing any var then changes the key.
With two prompts and two providers, a key looks like Refund above limit | prompts/support_v2.txt | openai:gpt-4o-mini.
pytest-json-report JSON
Each test is one eval, keyed the way pytest's JUnit XML keys it, so switching between the two keeps every eval's history. Skipped and xfailed tests are ignored.
| Field | Where it comes from |
|---|---|
| key | The test's module path with dots, its class if any, and its name: tests/evals/test_bot.py::TestTone::test_reply[haiku] becomes tests.evals.test_bot.TestTone.test_reply[haiku] |
| name | The test's name, test_reply[haiku] |
| suite | The stem of the test's file, bot |
| file | The file part of nodeid |
| passed | true when outcome is passed or xpassed |
| failure | The failing phase's crash.message, else its longrepr |
| output | The output user property, else the call's stdout |
| duration_ms | The setup, call and teardown durations added up, times 1000 |
user_properties, which pytest's record_property writes, work like JUnit properties: score, threshold, targets, input, output, cost_usd, tokens and url, and any other name goes into metadata.
Vitest or Jest JSON
Each test is one eval, keyed the way Vitest's JUnit XML keys it, so switching between the two keeps every eval's history. Skipped, pending and todo tests are ignored.
| Field | Where it comes from |
|---|---|
| key | The test file's path, a dot, then its describe titles and its title joined with >: evals/bot.eval.ts.Bot > refunds > quotes the policy |
| name | The describe titles and the title, Bot > refunds > quotes the policy |
| suite | The stem of the test's file, bot |
| file | The test file's path, relative to the repository |
| passed | true when status is passed |
| failure | failureMessages, joined |
| duration_ms | duration |
Inspect AI logs
Each sample is one eval, and one per scorer when the task has more than one. Upload the .eval files from Inspect's logs/ folder as they are.
| Field | Where it comes from |
|---|---|
| suite | The task's name |
| key | The sample's id. With more than one scorer, | <scorer> is appended. |
| file | The task's file |
| passed | The score is C, true, or a number of 1 or more. I, P, N, false and lower numbers fail, and so does a sample that ended in an error. |
| score | The score as a number: C is 1, P is 0.5, I and N are 0 |
| failure | The sample's error, else the scorer's explanation |
| input | The sample's input |
| output | The model's completion |
| duration_ms | total_time times 1000 |
| tokens | The sample's total tokens across models |
| metadata | The model, the scorer and the sample's own metadata |
A task run with --epochs gives each sample several results with the same key and input. evalship reads them as one eval with repeats (Stable keys): it passes when at least half of them pass.
evalship JSON
The schema is at evalship.com/schema/v1.json.
{
"schema": "evalship/v1",
"suite": "support-bot",
"url": "https://www.braintrust.dev/app/acme/p/support-bot/experiments/pr-142",
"evals": [
{
"key": "refund-above-limit",
"name": "Declines a refund above $100",
"passed": true,
"score": 0.92,
"threshold": 0.7,
"duration_ms": 1840,
"cost_usd": 0.0021,
"tokens": 812,
"input": "Can I get a refund of $500?",
"output": "I can refund up to $100. For larger amounts I'll pass you to a colleague.",
"file": "evals/support_bot.eval.ts",
"targets": ["app/support_bot.py#answer", "prompts/support_system.md"],
"url": "https://cloud.langfuse.com/project/acme/traces/4f1c9e",
"metadata": { "model": "gpt-4o-mini" }
}
]
}
The top-level suite and url apply to evals without their own. Only evals is required at the top level.
| Field | Type | Notes |
|---|---|---|
key |
string | Stable identifier. Each eval needs key or name; name is used as the key when key is missing. Up to 255 characters. |
name |
string | Display name. Up to 500 characters. |
suite |
string | Overrides the top-level suite. Default default. Up to 100 characters. |
passed |
boolean | Required unless both score and threshold are given; then passed is score >= threshold. |
score |
number or null | 0 to 1. |
threshold |
number or null | 0 to 1. |
duration_ms |
number or null | Rounded to whole milliseconds. |
cost_usd |
number or null | |
tokens |
integer or null | |
input |
any JSON value | Truncated to 4 KB. |
output |
any JSON value | Truncated to 4 KB. |
failure |
string or null | Why the eval failed. Truncated to 4 KB. |
file |
string or null | The eval file. Up to 500 characters. |
targets |
array of strings | Code the eval exercises, as path or path#symbol. Up to 50 entries. |
url |
string or null | Where the eval lives in another tool. See url. |
calls |
array or null | The AI calls behind the reply. See calls. |
otel |
string or null | The id this eval's run gave its OpenTelemetry spans, as evalship.eval. Up to 100 characters. See calls. |
metadata |
object | Stored as given. Values that aren't objects are dropped. |
Non-string values in input, output and failure are serialized as JSON before truncation.
calls
The AI calls an eval's reply took, in order, so its report shows what each model got and answered. Each is an object with call, its name, and optionally kind (ai, tool or step), model, input, output, error, input_tokens, output_tokens, cost in dollars and duration_ms. The first 20 calls that fit in 64 KB are kept, each input and output truncated to 16 KB and each error to 4 KB. tinyeval records them from TINYEVAL_CALLS.
An app that already sends OpenTelemetry traces can send them instead. In the eval job, set the OTEL_EXPORTER_OTLP_TRACES_* variables the repo's Monitor page gives when you connect production, and tag each eval's spans with evalship.eval, a resource or span attribute, set to the eval's otel. tinyeval does this for each run of ask when the endpoint is set. An eval with no calls of its own gets the AI call spans with its id that arrived before its results. They don't show on Monitor or count toward its spans. evalship deletes them the night after they arrive.
"calls": [
{ "call": "classify-ticket", "model": "claude-haiku-5-5", "input": "Can I get a refund of $500?", "output": "refund", "cost": 0.0001 },
{ "call": "support-chat", "model": "claude-opus-5-5", "input": "...", "output": "I can refund up to $100.", "input_tokens": 1840, "output_tokens": 61 }
]
url
If your evals also report to Langfuse, Braintrust, LangSmith or another platform, give each eval a url that opens it there, such as its trace. The PR comment links each changed eval's name to it, and the report shows a "View on" link next to each eval. Only http and https links are kept, up to 2,000 characters. Integrations has the code for each platform.
Stable keys
evalship matches evals across runs by suite and key. When either changes, the old eval shows up as removed and the new one as new, and its flakiness history starts over.
- Keep timestamps, random ids, dates and changing model names out of test names and keys.
- In pytest, give parametrized tests explicit
ids=so parameter values don't end up in the key. - In JUnit, the key includes the module path, so moving or renaming a test file changes its keys.
- In promptfoo, give every test a
description. - If the same suite and key appear twice in one file with the same input, they're one eval with repeats (see How the PR comment works). With different inputs, the later ones become
key (2),key (3)in file order. That depends on order, so give each eval its own key.
A workflow run's result files are read until they hold 20,000 evals; the files after that are skipped. A file with more than 20,000 evals is skipped whole, not cut. See Connecting CI for artifact and file size limits.