Real LLMs on every build path, honest docs, and three bugs behind shipped UI - #9
Merged
Conversation
Judge rubrics stored on a PipelineVersion were never materialised at run time, so any DB pipeline containing a judge check aborted the whole run with FileNotFoundError. _resolve_spec now returns rubrics alongside generated sources, _materialise_sources writes both into separate subdirectories, and _patch_spec_paths rewrites CheckSpec.rubric as well as CheckSpec.uses. The sandbox installed its import allowlist and removed open/exec/eval/compile only after exec_module(), leaving a generated check's module-level code completely uncontained -- it could import os and write files. The worker now reads and compiles the module while those builtins are still available, then locks down before executing any user code. The socket patch also moved ahead of the guard: socket is not allowlisted, so importing it afterwards raised and the patch was silently skipped. regenerate_check emitted `def <stem>(response, **kwargs): return True`, but the sandbox requires a module-level `check(response, context) -> dict`. Every regenerated check failed at run time with "module defines no check". The scaffold now matches the contract and fails with an explicit message until real codegen lands. Adds docs/user-journeys.md: twelve builder/integrator journeys mapping each step to its UI touchpoint, route, business logic and state effect, marked BUILT/PARTIAL/MISSING/BROKEN against the current code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
The README claimed MCP and SDK targets, a `custom` adapter, and OpenAPI import, none of which exist; it also described sandbox containment that was never implemented (filesystem isolation, deny-all egress). Those claims are corrected rather than softened -- each now says what is actually there. Adds docs/STATUS.md as the single source of truth for capability claims: a built/partial/planned matrix over builder, adapters, judges, sandbox, engine, review, server, exporters, CLI and storage, plus the roadmap phases. Adds tests/test_docs_truth.py so the mechanically checkable claims cannot drift again: the README adapter tables must match the adapter registry, every CLI verb named in the docs must exist or be declared Planned, the template-primitive count must match the registry, and a command listed as Planned must not already be implemented. Archives assay-ui-sprint-plan-prompt.md, whose locked decisions contradicted shipped behaviour and whose Phases 5 and 6 were never built, with a header explaining what actually happened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
Adds assay/llm/provider.py: one entry point for "which model, which key, and is it configured". Keys stay in the environment, referenced by variable name, and an unconfigured provider raises LLMConfigError carrying the adapter and the env var to set -- so callers can tell the user exactly what is missing instead of falling back to an offline heuristic. Builder and judge are separate roles: builder_choice() prefers explicit builder settings, falls back to the judge settings, then to a built-in default. The fallback is evaluated at read time rather than seeded, because _seed_settings() guards on a whole-table count and would never backfill an existing workspace. This is the frozen interface that the credential-plumbing and build-path workstreams are both developed against. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
A run blocked the HTTP request until every case finished. Against mock adapters that is instant; against real models it is minutes of a dead, disabled button with no indication anything is happening. This is journey J10.7, which the journey mapping surfaced as a real-LLM requirement that had never been written down. execute_run is split into _setup_run (resolve the spec, materialise artifacts, reach the target, create the Run row) and _execute_cases (the loop). Setup stays on the calling thread so an unreachable target or an inactive version still raises synchronously rather than disappearing into a background thread; only the case loop is deferred. start_run returns the run id immediately, and the browser is redirected to a progress view that polls done/total and forwards to the report when the run lands. Case results now commit per case rather than once at the end, which is what makes progress observable at all. A run that dies half way therefore keeps the results it produced, and failures are recorded on the run instead of being lost. Programmatic callers keep synchronous semantics -- CI and the webhook depend on the response carrying report_id. Also makes the hand-rolled migrations dialect-aware via _add_columns, since they emitted SQLite-only DDL while the README documents a Postgres switch, and opens SQLite with check_same_thread=False so background runs can write. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
…t sent Three credential bugs, one plumbing gap: * TargetSpec/JudgeSpec now declare key_env (and JudgeSpec an endpoint), so the variable name the wizard collects survives validation instead of being dropped by pydantic's extra="ignore". Both models are extra="forbid" now, so the next dropped field is a validation error rather than silence. * get_judge_provider passes model/endpoint/key_env/params through; every adapter reads them. Anthropic constructs its client with an explicit api_key so a per-target key is possible at all; openai_compat and rest raise LLMConfigError naming the variable instead of sending an empty bearer token. * ping() gained reachable/authenticated/env_var, so "reachable but has no credential" is no longer reported as a green Connected badge. * Adapters honour params["system"] -- judge verdicts were being requested without the judge's instructions on every real provider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
Adds tests/test_p0_credentials.py (33 tests, no network -- requests is monkeypatched and the Anthropic SDK is a fake module): key_env reaches every adapter, a missing key raises LLMConfigError naming the variable instead of sending an empty bearer, ping tells "unauthenticated" apart from "unreachable", and params["system"] is in the outbound payload for all three real providers. Settings gains a read-only Providers card (variable names and a configured badge, never a value) and a builder model selector backed by GET/POST /settings/builder. Anthropic's ping now checks the credential before importing the SDK, so a missing key is reported as a missing key. The seeded default claude-haiku-4-5-20251001 is now in the model_selector option list, so it no longer renders as "Custom...". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
The web UI never called a model. Both /pipelines/preview and /pipelines/generate hardcoded judge=None, so every pipeline built through the product's primary surface came from an offline keyword heuristic — and once persisted, it looked identical to one built from comprehension. Both routes now resolve the workspace build model through resolve_builder_llm and call it. Nothing falls back silently: an unconfigured provider, a provider that throws, or a model that replies with something other than usable intents all become a 422 whose detail names the environment variable to set or what went wrong. The heuristic survives as an explicit opt-in (derive_intents(..., allow_heuristic=True), assay generate --offline). Requirement traceability is real. generator/ingest.py splits requirements into R1..Rn across markdown headings, bullets, numbered lists and the wizard's one-sentence-per-line textarea; the prompt carries those ids; every returned requirement_ref is resolved back to one of them (repaired where plausible, rejected where not) instead of the "auto" stamp that collapsed the coverage matrix into a single bucket. intents_to_spec now emits one suite per requirement. Model output is validated before it becomes a persisted pipeline: `how` must be a known route, template names must exist in the registry, thresholds are coerced and clamped, and ids are sanitised because they land in generated/rubrics/<id>.yaml. Judge rubrics are now stored on the version by the generate route, which previously wrote a rubric path with nothing behind it. The wizard had no error handling at all — no r.ok check, no else branch — so a 4xx stopped the spinner and showed the user nothing. generate() and loadPreview() now check r.ok, tolerate a non-JSON body, and render the detail in a .banner-danger sitting directly under the adapter fields, with the wizard staying on step 2. CLI: --offline for the heuristic, bare `assay generate` resolves the configured build model, and --judge without a colon gets a real message instead of a traceback. Tests: new tests/conftest.py provides fake builder models, so the whole suite still passes with zero API keys and makes no network calls. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
P0 made openai_compat raise rather than send an empty bearer token, which is right for api.openai.com but breaks a local vLLM, LM Studio or llama.cpp server that wants no auth at all -- those users would have had to invent an API key. An explicit key_env="" now means "this target takes no credential": key_env_for distinguishes it from None (use the adapter default), and openai_compat omits the Authorization header entirely rather than sending one with an empty value. get_judge_provider filtered its kwargs on truthiness, so an empty key_env was dropped and the adapter silently fell back to demanding OPENAI_API_KEY. It now filters on `is not None`, matching get_target_adapter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
… workstreams POST /pipelines/preview took no identity and now calls a real model on every request, which made it an unauthenticated spend vector for anyone able to reach the port. It resolves identity like the other privileged routes: no change in open mode, 401 in enforced mode, which is the deployed posture. The credential and build-path workstreams each updated docs/STATUS.md and docs/user-journeys.md without seeing the other, so several rows still described gaps that are now closed -- provider credential status, per-target key_env, openai_compat's hardcoded variable, and the unauthenticated-vs-unreachable distinction on the connection badge. Reconciled against the merged tree. The ranked-gap table now carries closed items with the phase that closed them rather than dropping them, so the list stays auditable rather than just getting shorter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moves Assay off mocks and heuristics on the paths that matter, makes the docs match the code, and fixes three bugs that were reachable from the shipped UI.
273 tests passing with zero API keys set. Baseline was 149.
Why
A status audit found the runner half production-shaped and the builder half a v0 heuristic: the web UI never called an LLM, codegen did not exist, rubrics were templated, and mock adapters were the silent default. Docs claimed capabilities that were not there. This PR is the first three phases of closing that.
Bugs fixed (all were live in the UI)
_resolve_specreturned only generated sources, sojudges/judge.pyhitFileNotFoundErrorand aborted the entire run. Any DB pipeline with a judge check was dead. Reproduced first, then fixed.open/exec/eval/compilewere installed afterexec_module(). Verified as a real escape, not a theoretical one: against the old code a generated check's top-levelopen(path,'w').write(...)succeeded and the file existed. The worker now compiles the module while those builtins are available, then locks down before running any user code. A second bug found in passing: the socket patch ran after the import guard, soimport socketwas blocked and the patch was silently skipped.return Trueinstead of a dict. Now emits a contract-correct scaffold that fails with an explicit message until codegen lands.Real LLMs (P0 + P1)
assay/llm/provider.py: one entry point for which model, which key, is it configured. Keys stay in the environment, referenced by name, never persisted and never rendered. An unconfigured provider raisesLLMConfigErrorcarrying the adapter and the variable to set.key_envnow reaches the adapters. It was collected by the wizard and silently discarded, because the spec models did not declare it and pydantic ignored extras.openai_compatno longer hardcodesOPENAI_API_KEY, and judges getendpoint/key_env/paramsinstead of onlymodel— so a judge can point at a local vLLM./pipelines/previewand/pipelines/generateresolve the configured builder model. No key produces a 422 naming the exact variable, never a silent heuristic fallback.assay generate --offlineis the explicit opt-in to the old keyword path.generator/ingest.pysplits requirements intoR1…Rn; the prompt carries those ids, every returned ref is resolved against them, and each requirement becomes its own suite.requirement_ref: "auto"is gone.params["system"]and read by no adapter, so judge verdicts were being requested without their instructions.Run progress
A run blocked the HTTP request until every case finished — invisible against mocks, minutes of a dead button against real models.
execute_runis split into setup and execution; setup stays on the calling thread so an unreachable target still raises synchronously, and only the case loop is deferred. Browsers get a polling progress view; programmatic callers keep synchronous semantics because CI depends on the response carryingreport_id. Case results now commit per case, which is what makes progress observable and means a run that dies half way keeps what it produced.Docs
docs/STATUS.mdis now the source of truth: a built/partial/planned matrix over every subsystem. README's false claims are corrected rather than softened — no MCP/SDK targets, nocustomadapter, no OpenAPI import, and the sandbox section says plainly there is no filesystem jail and no egress block.docs/user-journeys.mdmaps twelve builder journeys to UI touchpoint, route, business logic and state effect. The stale UI sprint plan is archived with a note on what actually shipped.tests/test_docs_truth.pyenforces the mechanically checkable claims: README adapter tables must match the registry, documented CLI verbs must exist or be declared planned, and a verb listed as planned must not already be implemented.Behaviour changes worth review
TargetSpec/JudgeSpecare nowextra="forbid". Silently dropping an undeclared field is exactly what caused thekey_envbug. A hand-writtenassay.yamlwith an unknown key undertarget:now fails loudly at load.Specitself is unchanged, sospec_dict["requirements"]still round-trips./pipelines/previewnow requires identity. It calls a real model on every request, so unauthenticated it was an open spend vector. No change in open mode; 401 in enforced mode.key_env: ""means "this target takes no credential" — the escape hatch for a keyless local vLLM or LM Studio, which would otherwise have been forced to invent an API key.check_same_thread=Falseso background runs can write, and the hand-rolled migrations are dialect-aware (they emitted SQLite-only DDL while the README documents a Postgres switch).Verification
pytest -q— 273 passing, verified withANTHROPIC_API_KEYandOPENAI_API_KEYexplicitly unset. No test makes a network call.ready_for_review, zero server errors.generatewithout a key explains the three ways forward, a malformed--judgegives an example,--offlineworks and emits realR1/R2refs.Not in this PR
Codegen (P4) still does not exist, case inputs are still empty, the target interface is still not parsed at build time, and mock adapters are still selectable as ordinary targets (P6).
docs/STATUS.mdmarks all of these planned, and the ranked-gap table indocs/user-journeys.mdtracks them with the phase that closes each.Generated by Claude Code