{"title":"Catch promptfoo Config Mistakes That validate Lets Through","excerpt":"Import a promptfooconfig.yaml into a browser-only builder and see what the 0.123.1 schema lets through: unknown assertion types, prompts that render with a blank variable, unpinned graders, 0-to-1 thresholds written as percentages, and CSV cells that silently become equals checks.","canonicalUrl":"https://automationtester.in/blog/tools-reviews/catch-promptfoo-config-mistakes-before-eval","category":{"name":"Tools & Reviews","slug":"tools-reviews"},"tags":["promptfoo","llm-evals","llm-testing","ai-testing","yaml"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-10-30T05:30:00.000Z","dateModified":"2026-10-30T05:30:00.000Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/catch-promptfoo-config-mistakes-before-eval.webp","alt":"A config panel whose two flawed rows, a hollow dashed slot and a short orange pill, stop at crossed-out circles, while the clean rows pass through a green node into the exported file."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> Paste your <code>promptfooconfig.yaml</code> into the <a href=\"https://automationtester.in/tools/llm-eval-case-builder\">LLM Eval Test-Case Builder</a>, or build one in its form. It checks the file against the promptfoo 0.123.1 config schema and then reports four faults that schema accepts: an assertion type promptfoo does not have, a <code>{{variable}}</code> a test never sets, a model-graded check with no grader pinned, and a score threshold outside 0 to 1. Fix them in the form, download the file, and run <code>npx promptfoo@latest eval</code> yourself. The tool never calls a model.</div>\n\n<p>An eval that fails loudly is the easy case. The expensive one passes. A test named “Digital goods are not refunded” goes green on every run because the customer message it was meant to send never reached the prompt, and the model answered a question nobody asked. Nothing in the output says so. The config parsed, the provider answered, and a <code>not-icontains</code> check had nothing to object to.</p>\n\n<h2 id=\"promptfoo-refund-config\">A refund-assistant config with four quiet faults</h2>\n\n<p>This is a short, realistic promptfoo config. It sets a shared refund policy in <code>defaultTest</code> and adds two test cases:</p>\n\n<pre class=\"language-yaml\"><code class=\"language-yaml\">description: Refund assistant\nprompts:\n  - |\n    You answer refund questions for an online store.\n    Policy: {{policy}}\n    Customer message: {{message}}\nproviders:\n  - openai:gpt-5-mini\ndefaultTest:\n  vars:\n    policy: Refunds within 30 days of delivery. Digital goods are not refundable.\ntests:\n  - description: Late refund request is declined\n    vars:\n      message: I bought a jacket 45 days ago. Can I return it?\n    assert:\n      - type: contain\n        value: 30 days\n      - type: llm-rubric\n        value: Declines politely and explains the 30-day window.\n        threshold: 80\n  - description: Digital goods are not refunded\n    vars:\n      customer_message: Please refund the e-book I bought yesterday.\n    assert:\n      - type: not-icontains\n        value: refund has been issued</code></pre>\n<a href=\"https://automationtester.in/tools/llm-eval-case-builder#p=eyJ2IjoxLCJpbnB1dCI6ImRlc2NyaXB0aW9uOiBSZWZ1bmQgYXNzaXN0YW50XG5wcm9tcHRzOlxuICAtIHxcbiAgICBZb3UgYW5zd2VyIHJlZnVuZCBxdWVzdGlvbnMgZm9yIGFuIG9ubGluZSBzdG9yZS5cbiAgICBQb2xpY3k6IHt7cG9saWN5fX1cbiAgICBDdXN0b21lciBtZXNzYWdlOiB7e21lc3NhZ2V9fVxucHJvdmlkZXJzOlxuICAtIG9wZW5haTpncHQtNS1taW5pXG5kZWZhdWx0VGVzdDpcbiAgdmFyczpcbiAgICBwb2xpY3k6IFJlZnVuZHMgd2l0aGluIDMwIGRheXMgb2YgZGVsaXZlcnkuIERpZ2l0YWwgZ29vZHMgYXJlIG5vdCByZWZ1bmRhYmxlLlxudGVzdHM6XG4gIC0gZGVzY3JpcHRpb246IExhdGUgcmVmdW5kIHJlcXVlc3QgaXMgZGVjbGluZWRcbiAgICB2YXJzOlxuICAgICAgbWVzc2FnZTogSSBib3VnaHQgYSBqYWNrZXQgNDUgZGF5cyBhZ28uIENhbiBJIHJldHVybiBpdD9cbiAgICBhc3NlcnQ6XG4gICAgICAtIHR5cGU6IGNvbnRhaW5cbiAgICAgICAgdmFsdWU6IDMwIGRheXNcbiAgICAgIC0gdHlwZTogbGxtLXJ1YnJpY1xuICAgICAgICB2YWx1ZTogRGVjbGluZXMgcG9saXRlbHkgYW5kIGV4cGxhaW5zIHRoZSAzMC1kYXkgd2luZG93LlxuICAgICAgICB0aHJlc2hvbGQ6IDgwXG4gIC0gZGVzY3JpcHRpb246IERpZ2l0YWwgZ29vZHMgYXJlIG5vdCByZWZ1bmRlZFxuICAgIHZhcnM6XG4gICAgICBjdXN0b21lcl9tZXNzYWdlOiBQbGVhc2UgcmVmdW5kIHRoZSBlLWJvb2sgSSBib3VnaHQgeWVzdGVyZGF5LlxuICAgIGFzc2VydDpcbiAgICAgIC0gdHlwZTogbm90LWljb250YWluc1xuICAgICAgICB2YWx1ZTogcmVmdW5kIGhhcyBiZWVuIGlzc3VlZCJ9\">Try this in LLM Eval Test-Case Builder</a>\n\n<p>Every key in it is one promptfoo accepts, and every value has an accepted type. Checked with Ajv against the JSON schema published at the 0.123.1 tag, it validates. Choose <strong>Import YAML</strong> in the builder, paste it, and select <strong>Import config</strong>. The Findings panel lists one error, three warnings and one note:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">error    tests[1] \"Late refund request is declined\" › assert[1]\n         \"contain\" is not an assertion type in promptfoo 0.123.1. The schema accepts any\n         string here, so `promptfoo validate` passes and the eval fails with \"Unknown\n         assertion type\". Did you mean \"contains\"?\nwarning  tests[1] \"Late refund request is declined\" › assert[2]\n         The threshold is 80, but the score it is compared with runs from 0 to 1.\n         Nothing can reach it, so this always fails.\nwarning  tests[1] \"Late refund request is declined\" › assert[2]\n         1 model-graded assertion has no grader. [...]\nwarning  tests[2] \"Digital goods are not refunded\"\n         This test never sets {{message}}, which prompt 1 uses. [...]\ninfo     vars.customer_message\n         \"customer_message\" is set but no prompt or assertion reads it.</code></pre>\n\n<h2 id=\"promptfoo-why-schema-misses\">Why each one gets past the schema</h2>\n\n<p><strong>The misspelt type.</strong> In promptfoo's own zod definitions, <code>AssertionTypeSchema</code> is a union that ends in an unrestricted <code>z.custom()</code>, and the published JSON schema ends the same union with an empty <code>{}</code>. Any string is a valid <code>type</code>. The check happens only when the assertion runs: the dispatcher in <code>src/assertions/index.ts</code> finds no handler for <code>contain</code> and throws <code>Unknown assertion type: contain</code>. The builder carries the 66 base types from the 0.123.1 schema, their <code>not-</code> forms, and the special types <code>select-best</code>, <code>human</code> and <code>max-score</code>, and suggests the nearest match for anything else.</p>\n\n<p><strong>The renamed variable.</strong> The second test sets <code>customer_message</code>, but the prompt reads <code>{{message}}</code>. promptfoo renders prompts with Nunjucks, and its <code>getNunjucksEngine</code> helper leaves <code>throwOnUndefined</code> at <code>false</code>, which the Nunjucks API documents as its default. An undefined value renders as an empty string. The model receives “Customer message:” followed by nothing, and a negative check such as <code>not-icontains</code> passes against almost any reply to a blank message. The builder reads the root name of every <code>{{ }}</code>, <code>{% if %}</code> and <code>{% for … in … %}</code> in the inline prompts, subtracts the vars in <code>defaultTest</code> and the test itself, and warns per test. It leaves out names promptfoo supplies, such as <code>env</code> and <code>_conversation</code>. In the form, a test that is missing variables shows an <strong>Add missing {{message}}</strong> button.</p>\n\n<p><strong>The threshold of 80.</strong> The <code>llm-rubric</code> grader is asked for a score from 0.0 to 1.0, and in 0.123.1 the grading code sets <code>pass = pass &amp;&amp; score &gt;= threshold</code>. A threshold of 80 reads naturally as a percentage and can never be met. The builder applies this range check only where the threshold really is a 0 to 1 score: <code>llm-rubric</code>, <code>g-eval</code>, <code>similar</code>, the context and relevance graders, ROUGE, BLEU and a test-level or assert-set <code>threshold</code>. For <code>latency</code> the threshold is milliseconds, for <code>cost</code> it is dollars and for <code>levenshtein</code> it is an edit count, and none of those are flagged for exceeding 1.</p>\n\n<p><strong>A key promptfoo does not know.</strong> This sample has none, but the commonest one is <code>asserts:</code> for <code>assert:</code>. The published JSON schema marks the config, each test and each assertion as closed objects, so an editor that honours the <code>yaml-language-server</code> comment underlines the typo. promptfoo's loader does not complain. <code>TestCaseSchema</code> is a plain <code>z.object()</code>, and zod strips keys it does not recognise instead of failing, so <code>validate</code> passes and the test runs with no assertions at all. The builder reports every unknown key as an error.</p>\n\n<p><strong>The missing grader.</strong> With no provider on the assertion, the test's <code>options</code> or <code>defaultTest.options</code>, <code>llm-rubric</code> picks its grader from whichever credentials are present. The docs list <code>gpt-5</code> for an OpenAI key and <code>claude-sonnet-4-5-20250929</code> for an Anthropic key, among others. A laptop with one key and a CI runner with another grade the same output with different models. This one is a warning rather than an error because <code>--grader</code> on the command line is a legitimate fix the builder cannot see.</p>\n\n<h2 id=\"promptfoo-fix-in-form\">Fix it in the form and export</h2>\n\n<ol>\n<li>Change the first assertion's type from <code>contain</code> to <code>contains</code> in its dropdown. The dropdown groups the common types as deterministic, model-graded and performance checks, and the <code>not-</code> checkbox beside it negates any of them.</li>\n<li>Set the rubric threshold to <code>0.8</code>.</li>\n<li>Rename <code>customer_message</code> to <code>message</code> in the second test.</li>\n<li>Type <code>openai:gpt-5-mini</code> into <strong>Grader for model-graded checks</strong>. It is written to <code>defaultTest.options.provider</code>, so every rubric in the file uses the same judge.</li>\n<li>Check that the Findings panel is empty, then select <strong>Download</strong> to save <code>promptfooconfig.yaml</code>.</li>\n</ol>\n\n<p>The changed parts of the exported file:</p>\n\n<pre class=\"language-yaml\"><code class=\"language-yaml\">defaultTest:\n  vars:\n    policy: Refunds within 30 days of delivery. Digital goods are not refundable.\n  options:\n    provider: openai:gpt-5-mini\ntests:\n  - description: Late refund request is declined\n    vars:\n      message: I bought a jacket 45 days ago. Can I return it?\n    assert:\n      - type: contains\n        value: 30 days\n      - type: llm-rubric\n        value: Declines politely and explains the 30-day window.\n        threshold: 0.8\n  - description: Digital goods are not refunded\n    vars:\n      message: Please refund the e-book I bought yesterday.</code></pre>\n\n<p>Run the file where your API key lives. <code>promptfoo validate</code> is still worth the second it takes. It rejects faults the builder also reports, such as a variable left as a bare <code>message:</code>, which YAML reads as null, or a file with neither <code>providers</code> nor <code>targets</code>, before any tokens are spent:</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\">npx promptfoo@latest validate -c promptfooconfig.yaml\nnpx promptfoo@latest eval -c promptfooconfig.yaml</code></pre>\n\n<p>If the suite asserts <code>latency</code>, the run command shown under the YAML gains <code>--no-cache</code>. The latency handler throws on a cached response, which has no timing to measure. A <code>latency</code> or <code>cost</code> assertion without a threshold is reported as an error for the same reason: both handlers throw rather than guess.</p>\n\n<p>Anything the form does not model survives an import. Transforms, <code>rubricPrompt</code>, <code>evaluateOptions</code>, <code>env</code>, test metadata and <code>file://</code> test references are carried through and written back unchanged, so the builder can be used on an existing suite without flattening it.</p>\n\n<h2 id=\"promptfoo-csv-cells\">CSV cells are read more strictly than they look</h2>\n\n<p>promptfoo loads a CSV of test cases with <code>tests: file://tests.csv</code>. Ordinary columns become vars, and each <code>__expected</code> column holds one assertion written as <code>type: value</code>. <strong>Add tests from CSV</strong> parses the rows the way <code>src/csv.ts</code> in 0.123.1 does and shows the result as editable test cases:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">message,__expected1,__expected2\n\"Can I return shoes after 10 days?\",\"contains: 30 days\",latency\n\"Refund my gift card\",\"contain: not refundable\",\"latency(3000)\"</code></pre>\n\n<p>Two of those four cells do not mean what they appear to. <code>contain: not refundable</code> does not match the parser's pattern of known types, so it falls through to the default and becomes an <code>equals</code> check on the entire string <code>contain: not refundable</code>. It fails on every output with no hint of why. A bare <code>latency</code> matches, but the parser supplies a default threshold of <code>0.75</code> to latency, cost and several other types when none is written in parentheses. That is a 0.75 ms budget. The builder warns on both cells; <code>latency(3000)</code> is the form that means three seconds.</p>\n\n<h2 id=\"promptfoo-builder-limits\">What the builder does not check</h2>\n\n<p>It works on text in the config and nothing else. A prompt loaded with <code>file://prompt.txt</code> is not opened, so variables inside it are not compared with the tests, and the builder says so in a note rather than pretending the check passed. The same applies to vars loaded from files.</p>\n\n<p>It does not run your <code>javascript</code> or <code>python</code> assertions, compile your JSON Schema against real outputs, or know which environment variables your CI runner exports. It cannot tell whether a model will wrap JSON in a Markdown fence, which makes <code>is-json</code> fail because that type parses the whole output; <code>contains-json</code> accepts embedded JSON.</p>\n\n<p>The rules are pinned to promptfoo 0.123.1, the current release on 1 October 2026. <code>npx promptfoo@latest</code> will move on, and an assertion type added in a later version will be flagged as unknown here until the tool's list is updated. The version is printed in the page and in the YAML header so a mismatch is visible.</p>\n\n<h2 id=\"promptfoo-faq\">Questions about promptfoo configs</h2>\n\n<p><strong>Should CI use <code>promptfoo@latest</code>?</strong> For a gate that decides merges, pin the version you validated against, for example <code>npx promptfoo@0.123.1 eval</code>, and upgrade on purpose. <code>@latest</code> is fine for a local run, but it changes the evaluator under a suite that has not changed.</p>\n\n<p><strong>What does <code>weight: 0</code> do?</strong> promptfoo marks an assertion with weight 0 as passed whatever it finds, which is useful for recording a metric without gating on it. The builder notes it so it is not mistaken for a disabled check.</p>\n\n<p><strong>Where does a schema for <code>is-json</code> come from?</strong> Write it from the output contract, or start from a captured response in the <a href=\"https://automationtester.in/tools/json-schema\">JSON to Schema Generator</a> and review which fields are really required, as described in <a href=\"https://automationtester.in/blog/tools-reviews/review-generated-json-schema-required-fields\">Review Required Fields in a Generated JSON Schema</a>. Paste it into the assertion's value box as JSON or YAML; the export writes it as a mapping.</p>\n\n<p><strong>Is the builder a YAML formatter?</strong> No. A YAML parser can say the file is well-formed, which is the check in <a href=\"https://automationtester.in/blog/tools-reviews/review-yaml-conversion-before-ci\">Review YAML Conversion Before Changing a CI File</a>, but not that <code>asserts</code> should be <code>assert</code>. For a generated eval target, the reviewing habits in <a href=\"https://automationtester.in/blog/ai-in-testing/review-ai-generated-playwright-tests-before-merge\">Review AI-Generated Playwright Tests Before You Merge Them</a> translate directly into assertions: a <code>not-contains</code> on <code>waitForTimeout</code> is one line.</p>\n\n<h2 id=\"promptfoo-apply-now\">Apply this now</h2>\n\n<p>Import the promptfoo config your team runs most often. Resolve every error, then read each <code>missing-var</code> warning against the test's description: those are the tests most likely to have been passing without testing anything. Pin a grader, save the file, and run <code>validate</code> followed by <code>eval</code>. Keep the Findings list from before the fix in the pull request, so a reviewer can see which green results were never real.</p>\n\n<h2 id=\"promptfoo-references\">promptfoo and Nunjucks references</h2>\n<ul>\n<li><a href=\"https://www.promptfoo.dev/docs/configuration/expected-outputs/\">promptfoo: Assertions and metrics</a>: assertion properties, <code>not-</code> negation, weights, test thresholds, and weight 0 passing automatically.</li>\n<li><a href=\"https://www.promptfoo.dev/docs/configuration/expected-outputs/deterministic/\">promptfoo: Deterministic metrics</a>: <code>contains-any</code> lists, <code>is-json</code> with a schema, <code>latency</code> needing <code>--no-cache</code>, <code>cost</code> thresholds.</li>\n<li><a href=\"https://www.promptfoo.dev/docs/configuration/expected-outputs/model-graded/llm-rubric/\">promptfoo: LLM Rubric</a>: the 0.0 to 1.0 score, default graders per API key, and overriding the grader.</li>\n<li><a href=\"https://www.promptfoo.dev/docs/configuration/test-cases/\">promptfoo: Test case configuration</a>: CSV test files and the <code>__expected</code> column syntax.</li>\n<li><a href=\"https://www.promptfoo.dev/docs/usage/command-line/\">promptfoo: Command line</a>: <code>validate</code>, <code>--no-cache</code>, <code>--grader</code> and <code>-r</code>.</li>\n<li><a href=\"https://raw.githubusercontent.com/promptfoo/promptfoo/0.123.1/site/static/config-schema.json\">promptfoo 0.123.1 config JSON schema</a>: the assertion type list, and the closed key sets for the config, tests and assertions.</li>\n<li><a href=\"https://github.com/promptfoo/promptfoo/blob/0.123.1/src/csv.ts\">promptfoo 0.123.1 <code>src/csv.ts</code></a>: the <code>__expected</code> parser, its fallback to <code>equals</code>, and the 0.75 default threshold.</li>\n<li><a href=\"https://github.com/promptfoo/promptfoo/blob/0.123.1/src/assertions/index.ts\">promptfoo 0.123.1 <code>src/assertions/index.ts</code></a>: the “Unknown assertion type” error at run time.</li>\n<li><a href=\"https://mozilla.github.io/nunjucks/api.html\">Nunjucks API</a>: <code>throwOnUndefined</code> defaults to <code>false</code>.</li>\n<li><a href=\"https://automationtester.in/tools/llm-eval-case-builder\">AutomationTester.in LLM Eval Test-Case Builder</a>: the import, findings and export shown above.</li>\n</ul>\n"}