Promote null-content detection to upstream 502 — cascade now retries instead of returning silent 200

2026-05-20

routercompletionsintelligence

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:

  1. 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.

  1. 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 null content 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/completions may 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.