LlmComposer: fake-HTTP-engine tests + optional eval route#88
Merged
Conversation
Implements M1 of docs/plans/zai-glm-provider-addendum.md: make the
existing LlmComposer fully testable and wire an LLM-composed eval route
alongside the template-composed one.
LlmComposer HTTP tests (server/src/test/.../LlmComposerHttpTest.kt):
(a) success — validated LLM prose returns composerId llm-v1
(b) validation-failure — forbidden phrase + uncited prose fall back
to template-v1
(c) budget-exceeded — cost ceiling enforced before the HTTP call
(d) missing-env — selectComposer returns the template when the key
or token prices are absent/negative
Production changes:
- Extracted LlmHttpTransport (fun interface) as a pluggable fake
HTTP engine for OpenAiCompatibleLlmClient; production still uses
java.net.http.HttpClient, tests inject a lambda.
- Extracted selectComposer() from defaultDependencies so the env-
gating logic is unit-testable without a database.
- Made ChatRequest/ChatMessage/ChatResponse data classes so tests
can assert against the serialized request body.
Eval harness:
- New local-llm-compose route (OPTIONAL, env-gated) produces a
scorecard row alongside local-template. When COACH_LLM_API_KEY +
token prices are set (e.g. z.ai GLM), it scores real LLM-composed
openings; otherwise it shows as optional so the CI grounding gate
never depends on a live provider.
z.ai/GLM API details recorded for the PR description (checked
2026-07-13): base URL https://api.z.ai/api/paas/v4/chat/completions,
Bearer-token auth (no JWT), OpenAI-compatible request/response.
Models: glm-4.6 ($0.60/$2.20 per M tokens) is the cost-fit; free
tier glm-4.5-flash / glm-4.7-flash available. All provider config
comes from env vars only — nothing z.ai-specific is committed.
The GLM motivation for this branch is moot: ferryman-mcp #10 (merged 2026-07-17) ran the multi-provider eval it was waiting on and ranked z.ai's GLM last on company-research (68%) and tied-last on chess (52%) at ~10x gemini's cost and ~24x its latency. There is no eval case for GLM as this app's composer. What survives is the provider-agnostic test infrastructure. Strip the moot GLM framing from surface artifacts only — no logic changes: - Rename docs/plans/zai-glm-provider-addendum.md -> llm-composer- provider-infra.md and rewrite to provider-neutral framing; record the ferryman verdict and mark M2/M3 answered cross-repo. - selectComposer KDoc: 'provider-shaped, not z.ai-shaped' -> 'not tied to any vendor'. - LlmComposerHttpTest: KDoc doc reference updated; fixture model glm-4.6 -> gpt-4.1-mini (the production default; same serialization coverage). All 23 :server tests green (16 existing + 7 new LlmComposer tests).
ber4444
marked this pull request as ready for review
July 17, 2026 14:40
Owner
Author
|
Reframed out of draft. This was held as draft waiting on a cross-repo GLM eval. That eval ran (ferryman-mcp #10) and ranked GLM last on both skills at ~10× gemini's cost and ~24× its latency — so the GLM motivation is dead and I'm landing only what's provider-agnostic: the fake-HTTP-engine test seam, the The reframe was surface-artifacts only — no
|
The provider-shaping plan is fully resolved (M1 done in this PR; M2/M3 answered cross-repo by ferryman-mcp #10), so it no longer needs to live in the tree. Remove docs/plans/llm-composer-provider-infra.md and drop the dangling reference to it from the LlmComposerHttpTest KDoc. The four test scenarios are self-evident from the test names below the KDoc, so the citation added nothing.
The optional local-llm-compose route timed out (UncompletedCoroutinesError:
"After waiting for 1m, the test body did not run to completion") whenever it
ran with a real COACH_LLM_API_KEY. Root cause: EvalMain.main() was
`fun main() = testApplication { ... }`, and the LLM route ran inside that
block. testApplication wraps its body in runTestWithRealTime, whose timeout
is hardcoded 60s — Ktor's testApplication calls runTestWithRealTime$default
with the timeout arg defaulted (flags iconst_2), so neither a passed
CoroutineContext nor an inner withTimeout can extend it. ~10 sequential
blocking HTTP calls to an LLM provider blow past 60s, and runTest's watchdog
cancels with UncompletedCoroutinesError.
The 60s ceiling can't be lifted, so the fix is structural: the LLM route
calls composer.compose() directly (not the test HTTP server), so it doesn't
need testApplication at all. Split main() into:
- runTestApplicationRoutes(): deterministic + local-HTTP routes stay in
testApplication (all fast, fit the ceiling).
- evaluateLlmComposed(): runs in a plain runBlocking AFTER, with no
ceiling. compose() still hops to Dispatchers.IO so each blocking call
runs off the caller thread.
Also makes the per-run LLM cost cap configurable via
COACH_LLM_MAX_USD_CENTS (USD cents; default 0.2 = $0.002). The hardcoded
default trips partway through a real eval at gpt-4.1-mini prices
(~$0.40/$1.60 per 1M tokens, 10 cases land right at the cap), which makes
the tail fall back to the template and pollutes the local-llm-compose row
with mixed outputs. Set COACH_LLM_MAX_USD_CENTS=5 ($0.05) for a clean full
run.
Verified: ./gradlew :evals:run completes with a real key (was timing out
at the 60s ceiling). No-key path (CI's grounding gate) stays green with
the route showing "COACH_LLM_API_KEY or token prices not set".
…innable
The local-llm-compose row showed 100% fallback — every case fell back to the
template, so the row measured the template, not the composer. Root cause was a
fixture/validator mismatch, not the cost cap or the client:
OpeningExplanationValidator grounds each output sentence by requiring >=2
content-word overlaps with the cited passage's text (a sound check, designed
for passages that are real prose — see LlmComposerHttpTest's known-good
fixture). But caseSpecificOpeningDependencies built passages whose text was
just expectedConcepts.joinToString(", ") + "." — bare tags like
"development, center." — ~2 tokens of chess vocabulary. After the validator
strips the source-id substring, stopwords, and <4-char tokens, the usable
grounding universe per passage is 3-4 words, and the model would need to
confine EACH of its 2-3 sentences to that tiny vocabulary. Even a correct,
instruction-following output fails the per-sentence overlap check. Verified
by simulation: a best-case grounded output against the old passage shape
returns overlap=set().
Fix: build prose passages (openingConceptsPassage). The case-specific
expectedConcepts line is prepended (keeps retrieval case-distinguishing),
then a common backbone explains the standard opening ideas (development,
central control, king safety) in real sentences — giving any fluent grounded
explanation enough vocabulary to satisfy the validator per sentence. Mirrors
how the unit test's passing fixture is shaped (rich sentence-form passages).
Validator is unchanged — production grounding semantics are not touched; only
the eval's fixture passages change. Verified the new passage shape grounds
model output across all five expectedConcepts values in the golden set
(center, development, king safety, counterplay, pawn tension), and the
local-template route still scores 0.0% grounding violation with the new
passages (CI grounding gate stays green).
…or unchanged) Investigation of the local-llm-compose route's near-100% fallback rate showed the model was producing fluent, grounded prose but ignoring the validator's output contract (per-sentence [source-id] citations, 2-3 sentences, <300 chars). The old prompt buried the format requirement in one trailing sentence with no concrete example. Strengthen the prompt to be format-coercive: - System prompt leads with "You MUST follow the output format exactly" and restates the bracketed-id rule as mandatory in every sentence. - User prompt spells out hard constraints: EXACTLY 2 or 3 sentences, under 280 characters, every sentence ending with a bracketed source id. - Few-shot example built from the actual passage text, showing the exact [source-id] format in context — models comply far better with a literal demonstration than an abstract instruction. - MAX_OUTPUT_TOKENS 120 -> 90: 300 chars ~= 75 tokens; 90 allows headroom without inviting a paragraph past MAX_OUTPUT_CHARS (300). Validator is unchanged — production anti-hallucination semantics preserved. Measured effect (100 opening cases, COACH_LLM_MAX_USD_CENTS=20): model pass dominant failure gpt-4.1-mini ~1% citation compliance (ignores [source-id] format) gpt-4.1 11% sentence-structure (cited+short but not per-sentence) So model tier is the primary bottleneck for this output contract, not the prompt: gpt-4.1 cites reliably where gpt-4.1-mini largely doesn't. The remaining ~89% of gpt-4.1 failures are grounded and under length but fail the per-sentence citation rule — a prompt/validator structural mismatch, where the model converges on a single cited sentence rather than repeating the citation across 2-3. Answer to the PR's question: at these tiers LLM composition does not beat the deterministic template under the production output contract, so the template/fallback path remains the right production choice. Debug instrumentation added during the investigation has been removed.
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.
Summary
Adds an optional
local-llm-composeeval route that scores an OpenAI-compatible LLM composer against the same case-specific retrieval as the deterministic template route, plus a pluggable HTTP transport (LlmHttpTransport/OpenAiCompatibleLlmClient.forTesting) that makes the cloud client unit-testable with a lambda fake instead of a mock library.What landed
Four commits on this branch:
LlmHttpTransport+forTestingfactory + optional eval route — the original PR scope. The route is OPTIONAL: only runs whenCOACH_LLM_API_KEY+ token prices are set, so the CI grounding gate never depends on a live LLM.Hoist the LLM route out of
testApplication— the route timed out (UncompletedCoroutinesError: After waiting for 1m) whenever run with a real key.testApplicationwraps its body inrunTestWithRealTimewith a hardcoded 60s ceiling that can't be extended (verified by decompiling Ktor 3.4.3 —testApplicationcallsrunTestWithRealTime$defaultwith the timeout arg defaulted). Fix: the route callscomposer.compose()directly (not the test server), so it runs in a plainrunBlockingaftertestApplicationfinishes the fast deterministic routes. Also addsCOACH_LLM_MAX_USD_CENTS(default $0.002) so the cost cap can be raised for a full run.Enrich opening-concept passages to prose — a real latent bug.
OpeningExplanationValidatorgrounds each output sentence by requiring ≥2 content-word overlaps with the cited passage; the eval built passages whose text was bare tags ("development, center."), giving the model ~2 tokens of vocabulary. Now builds prose passages (validator unchanged).Format-coercive prompt — see findings below. Validator unchanged.
How to run it locally
Findings (the answer to "does LLM composition beat the template?")
Investigating a near-100% fallback rate produced a clean, three-run comparison. The headline: at these model tiers, no — the template/fallback path is the right production choice under the current output contract.
[source-id]formatTwo separable results:
[eval-opening-NNN]) where gpt-4.1-mini largely didn't.OpeningExplanationValidatorrequires every sentence to repeat the citation, and gpt-4.1 converges on a single cited sentence rather than repeating[id]across 2-3. That's the validator's deliberate anti-hallucination design — and it's incompatible with how even capable LLMs naturally write.This is the informative outcome the design doc (
evals/docs/litert-faithfulness-bypass.md) predicts for a related scorer: a quality gate that catches what a looser check can't. Whether to relax the validator's per-sentence rule (production semantics change) or accept the template as the production choice is a separate decision — this PR establishes the measurement, it doesn't change the contract.Verification
./gradlew :evals:runcompletes with a real key (was timing out at the 60s ceiling).local-llm-composeshows "COACH_LLM_API_KEY or token prices not set".:server:testpasses — theLlmHttpTransportfake covers success / validation-failure / budget / missing-env cases.local-templateroute still 0.0% grounding violation after the prose-passage change.Changed files
server/.../Application.kt—selectComposer(extracted for testability) +COACH_LLM_MAX_USD_CENTSenv var.server/.../Composers.kt—LlmHttpTransport/OpenAiCompatibleLlmClient.forTesting/ strengthened format-coercive prompt.server/.../LlmComposerHttpTest.kt— fake-engine tests (no mock library).evals/.../EvalMain.kt—evaluateLlmComposedroute (hoisted out oftestApplication) + prose passages.evals/scorecard.md— the verified gpt-4.1 run row.