Promote null-content detection to upstream 502 — cascade now retries instead of returning silent 200
2026-05-20
What We Built
A shared null-content predicate (src/router/null-content-detect.ts) and two behavior changes that promote "upstream returned HTTP 200 with empty body" from a silent passthrough to a real upstream failure:
model-executor.ts:1409— when the upstream completion has
content:null / empty + finish_reason:length and no tool_calls, throw ModelExecutionError(message, 502, endpointId). The existing fallback path (model-router.ts:868+, gated on isFallbackCandidate || isFlexibleRoute) then re-routes via the auto-selector with the failing model excluded.
non-streaming.ts:947— belt-and-suspenders. If cascade is disabled
(strict single-model route) or exhausted and we still see null at the response handler, return a structured 502 ErrorEnvelope with recovery.action: "retry_with_params" instead of a silent 200.
Why It Matters
The OpenClaw quality stress on 2026-05-20 (docs/assessment/openclaw-baseline-2026-05-20/) caught this failure mode: auto:floor requests with tiny max_tokens budgets sometimes routed to Google Gemini, which returned 200 with content:null + finish_reason:length. BR previously detected and headered (X-BR-Null-Content: true) — but returned the broken 200 anyway. The attempt-3 repro in docs/assessment/openclaw-baseline-2026-05-20/repro/A-null-content.md even shows the cascade compound: the broken response got cached, and a subsequent identical request returned x-br-route-reason: semantic-cache with the same null body.
Pre-fix damage was three-fold:
- Bandit signal pollution:
recordOutcome(success: true, costUsd: 0)
meant a model that returned nothing looked better in the Thompson posterior than one that did real work. Over time the bandit weighted toward the broken model.
- Cache amplification: null responses got stored and re-served, turning a
flaky upstream failure into a sticky one.
- Client confusion: 200 OK with
nullcontent doesn't fit the OpenAI
completion contract; downstream SDKs that assume content is a string silently broke.
How It Works
// src/router/null-content-detect.ts
export function classifyNullContent(response): NullContentVerdict {
const choice = response?.choices?.[0];
if (!choice) return EMPTY_VERDICT;
const hasToolCalls = Array.isArray(choice.message?.tool_calls)
&& choice.message.tool_calls.length > 0;
if (hasToolCalls) return { isNullContent: false, ... };
const content = choice.message?.content;
if (content === null || content === undefined) {
return { isNullContent: true, reason: "null_content_no_tool_calls", ... };
}
if (content === "" && choice.finish_reason === "length") {
return { isNullContent: true, reason: "empty_string_length_truncation", ... };
}
return { isNullContent: false, ... };
}
Tool-use responses (tool_calls present) are never classified as failure — the response shape is correct even with content: null.
Reward path: the bandit's recordOutcome is only called on the success path. Since the executor now throws on null content, recordOutcome(success: true) is bypassed entirely for these cases. Phase 4 (H5) will add explicit content-aware reward scoring for the non-null-but-low-quality cases.
Lockstep Checklist
- [x] API contract change:
POST /v1/chat/completionsmay now return 502 with
ErrorEnvelope where it previously returned 200-with-null. Documented in response handler comment.
- [x] SDKs: no method changes — error path uses standard ErrorEnvelope shape;
existing SDK error handling forwards untouched.
- [x] MCP tools: not affected.
- [x] Ship-log entry (this file).
- [x] Tests: 11 unit tests for the predicate; integration covered via existing
cascade tests that exercise the ModelExecutionError(502) path.
OpenClaw Plan Reference
Phase 1 of .claude/notes/openclaw-stress-fixes-phased-plan-2026-05-20.md. Phases 2-7 to follow.