mvanhorn/last30days-skill · error · ValueError

Gemini response did not contain text.

Error message

Gemini response did not contain text.

What it means

Raised by extract_gemini_text() in the search-quality evaluator after a Gemini generateContent call succeeded at the HTTP level but no candidate contained a non-empty text part. This happens when the model returns only empty parts, a finishReason like SAFETY/MAX_TOKENS with no content, or an empty candidates array. The evaluator requires JSON text output (responseMimeType=application/json), so a contentless response cannot be scored.

Source

Thrown at skills/last30days/scripts/evaluate_search_quality.py:212

def resolve_google_judge_api_key(config: dict[str, Any]) -> str | None:
    return (
        os.environ.get("GOOGLE_API_KEY")
        or config.get("GOOGLE_API_KEY")
        or os.environ.get("GEMINI_API_KEY")
        or config.get("GEMINI_API_KEY")
        or os.environ.get("GOOGLE_GENAI_API_KEY")
        or config.get("GOOGLE_GENAI_API_KEY")
    )


def extract_gemini_text(payload: dict[str, Any]) -> str:
    for candidate in payload.get("candidates") or []:
        content = candidate.get("content") or {}
        for part in content.get("parts") or []:
            if part.get("text"):
                return part["text"]
    raise ValueError("Gemini response did not contain text.")


def call_gemini_judge(api_key: str, model: str, prompt: str) -> dict[str, Any]:
    body = {
        "contents": [{"parts": [{"text": prompt}]}],
        "generationConfig": {"temperature": 0, "responseMimeType": "application/json"},
    }
    request = Request(
        GEMINI_API_URL.format(model=model, api_key=api_key),
        data=json.dumps(body).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urlopen(request, timeout=120) as response:
            payload = json.loads(response.read().decode("utf-8"))
    except HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")

View on GitHub (pinned to c7460f6114)

Solutions

  1. Inspect payload['candidates'][0].get('finishReason') and promptFeedback.blockReason before treating the response as scorable; log the full payload once.
  2. If blockReason/SAFETY: shorten or sanitize the judge prompt items (titles/urls at line 235-240) and retry.
  3. If empty candidates persists, retry with a different model or lower maxOutputTokens pressure; add a retry loop around call_gemini_judge.
  4. Catch ValueError at the call site in evaluate_search_quality.py and surface 'judge returned no text (finishReason=X)' instead of a bare ValueError.

Example fix

// before
return json.loads(extract_gemini_text(payload))

// after
finish = (payload.get('candidates') or [{}])[0].get('finishReason')
block = (payload.get('promptFeedback') or {}).get('blockReason')
if block or not finish or finish not in ('STOP', 'MAX_TOKENS'):
    raise RuntimeError(f'Gemini judge produced no text (finishReason={finish}, blockReason={block})')
return json.loads(extract_gemini_text(payload))
Defensive patterns

Strategy: try-catch

Validate before calling

def gemini_payload_has_text(payload: dict) -> bool:
    return any(
        part.get('text')
        for cand in payload.get('candidates') or []
        for part in (cand.get('content') or {}).get('parts') or []
    )

Type guard

def is_text_gemini_payload(payload: Any) -> TypeGuard[dict]:
    return (
        isinstance(payload, dict)
        and isinstance(payload.get('candidates'), list)
        and gemini_payload_has_text(payload)
    )

Try / catch

try:
    verdict = call_gemini_judge(api_key, model, prompt)
except ValueError as exc:
    finish = (payload.get('candidates') or [{}])[0].get('finishReason', 'UNKNOWN')
    raise RuntimeError(f'judge produced no text (finishReason={finish}); retry with sanitized prompt') from exc

Prevention

When it happens

Trigger: POST to GEMINI_API_URL generateContent with generationConfig {temperature: 0, responseMimeType: 'application/json'}; response payload where candidates is [], or every candidate's content.parts contains no part with a truthy 'text' field (e.g. finishReason=SAFETY, RECITATION, or empty completion).

Common situations: Safety filters blocking the prompt (judge prompt includes scraped titles/urls that trip filters); API key quota/billing issues that return a 200 with empty candidates; model returning thoughts only; prompt too long causing MAX_TOKENS with no text.

Related errors


AI-assisted analysis of mvanhorn/last30days-skill@c7460f6114 (2026-08-15). Data as JSON: /api/errors/55806171c2e004f1. Report an issue: GitHub.