Groq Returned Empty Content. The Bug Was Hiding in Reasoning Tokens. A developer at Jo4 Blog discovered that Groq's gpt-oss-safeguard model was returning empty content for ambiguous pages, causing links to remain in 'preview pending' status. The root cause was that the model's reasoning tokens consumed the entire max_tokens budget, leaving no tokens for the actual content. The fix involved switching to max_completion_tokens, increasing the budget to 1024, and adding a warning when reasoning tokens approach the budget. This article was originally published on Jo4 Blog . We use Groq's gpt-oss-safeguard model to classify pages behind freshly created short links. Most pages take a few hundred tokens to score. Some don't. And the ones that don't were silently failing — for weeks — until we noticed the symptom: a small but consistent stream of links stuck in "preview pending" forever. Here's what we found. The classifier wraps a single Groq chat completion. Send page text, get back a JSON verdict safe , unsafe , with category codes . For 95% of links, this works in well under a second. For the other 5%, we'd see this in logs: WARN Empty content in Groq response WARN Classification failed for shortUrl=xyz123 — preview stays enabled Empty content. Not a network error, not a rate limit, not malformed JSON. The API returned 200, the choices array had one entry, and choices 0 .message.content was "" . What did those pages have in common? They weren't obvious spam. They weren't obvious safe. They were ambiguous — a wellness blog that mentioned medication dosages, a forum thread about firearms law, a satire site quoting violent rhetoric. The kind of content where a human reviewer would also pause. Our first instinct: the model is rate-limited or degraded for hard inputs. We added retries. The empty-content rate didn't budge. Second guess: we're hitting max tokens . We had set it to 200. Maybe ambiguous pages produce longer verdicts. We bumped it to 400. Empty content rate didn't budge. The clue we kept missing was sitting in the response body itself, in a field we weren't parsing. Groq's response includes a usage block, and usage.completion tokens details.reasoning tokens was the smoking gun: { "choices": { "message": { "content": "" }, "finish reason": "length" } , "usage": { "completion tokens": 200, "completion tokens details": { "reasoning tokens": 200 } } } gpt-oss-safeguard is a reasoning model. Before emitting a single character of content, it spends completion tokens on internal chain-of-thought. Easy pages spend a few dozen reasoning tokens, then emit a 50-token verdict. Ambiguous pages spend several hundred reasoning tokens — and on those, our 200-token budget was being exhausted inside the reasoning phase , leaving zero tokens for content. The API obediently returned the response. choices 0 .message.content was "" because there was nothing left in the budget to write into it. finish reason was length , not stop — the model didn't decide it was done, the token budget cut it off mid-thought. We were paying for full inference and getting empty strings. Three changes: 1. Switch from max tokens to max completion tokens — max tokens is deprecated for reasoning models. Use the correct parameter name so the API enforces the limit you mean. 2. Raise the budget with headroom. We profiled real ambiguous pages: worst case was ~550 reasoning tokens. We set the budget to 1024 — covers worst case plus content with margin to spare. static final int MAX COMPLETION TOKENS = 1024; String requestBody = objectMapper.writeValueAsString Map.of "model", modelName, "messages", List.of Map.of "role", "system", "content", SAFETY POLICY , Map.of "role", "user", "content", text , "max completion tokens", MAX COMPLETION TOKENS, "temperature", 0.0 ; 3. Parse usage and alert when reasoning tokens approach the budget. This is the part that actually prevents the next regression: if reasoningTokens = null && reasoningTokens MAX COMPLETION TOKENS 0.8 { log.warn "Classifier reasoning tokens near budget: {}/{} — " + "consider raising max completion tokens", reasoningTokens, MAX COMPLETION TOKENS ; } When reasoning crosses 80% of the budget, we log a warning. The next ambiguous page in that distribution is the one that will trip finish reason=length and return empty content. We'd rather raise the budget before users see stuck previews, not after. We also added the diagnostic to the empty-content branch: if rawContent == null || rawContent.isBlank { String finishReason = firstChoice.getFinishReason ; log.warn "Empty content finish reason={} completion tokens={} " + "reasoning tokens={} ", finishReason, completionTokens, reasoningTokens ; return ClassificationResult.error "Empty classifier output finish reason=" + finishReason + " " ; } So if it ever happens again, the next person debugging it has the answer in the first log line, not after a week of squinting. max tokens is a budget the model spends thinking "" and a finish reason: length that you have to parse to see. finish reason is the field that tells you the truth. stop = model is done. length = the model wanted to keep going and you didn't let it. Treat them as completely different outcomes. completion tokens details.reasoning tokens is the leading indicator. max tokens was the wrong field name for reasoning models. The API silently honored it anyway, which made the bug subtler. The right field name is max completion tokens . Have you been bitten by an LLM that "succeeded" with no output? What was your tell? Drop it in the comments. Building jo4.io — a URL shortener with AI-backed content scanning that fails loudly, not silently.