From d517a3fdf65092dea26feb37c6d622705f07be02 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 06:41:49 +0200 Subject: [PATCH] fix: an empty model reply is logged with its finish reason, and a pool batch that gets one is retried once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two batches in five came back as a 200 with an empty message — nothing in the gateway log, nothing in ours. callLiteLLM now says when that happens (finish reason, completion tokens, whether the tokens went into reasoning), and the starter-question build tries such a batch once more instead of writing the category off. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- src/utils/ai.js | 16 +++++++++++++--- src/utils/clinicalPromptPool.js | 9 ++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/utils/ai.js b/src/utils/ai.js index 81cd75e6..7cbc48ee 100644 --- a/src/utils/ai.js +++ b/src/utils/ai.js @@ -366,14 +366,24 @@ async function callLiteLLM(messages, model, temperature, maxTokens, generation) max_tokens: maxTokens }, generation || {}), generation)); + var choice = completion.choices[0]; + // An empty reply with a 200 is the one failure nothing else reports: the + // gateway is happy, the caller gets '' and a JSON task keeps nothing. Say + // what the model said about it — the finish reason and whether the tokens + // went into reasoning instead of the answer. + if (!choice.message.content && !choice.message.tool_calls) { + var reasoningChars = String(choice.message.reasoning_content || choice.message.reasoning || '').length; + console.warn('[ai] empty reply from ' + model + ': finish_reason=' + (choice.finish_reason || '?') + + ' completion_tokens=' + (completion.usage && completion.usage.completion_tokens) + ' reasoning_chars=' + reasoningChars); + } return { success: true, - content: completion.choices[0].message.content, - ...(completion.choices[0].message.tool_calls ? { toolCalls: completion.choices[0].message.tool_calls } : {}), + content: choice.message.content, + ...(choice.message.tool_calls ? { toolCalls: choice.message.tool_calls } : {}), model: model, provider: 'litellm', usage: completion.usage || null, - finishReason: completion.choices[0].finish_reason || null + finishReason: choice.finish_reason || null }; } diff --git a/src/utils/clinicalPromptPool.js b/src/utils/clinicalPromptPool.js index bed5a653..dcb468e9 100644 --- a/src/utils/clinicalPromptPool.js +++ b/src/utils/clinicalPromptPool.js @@ -360,7 +360,14 @@ function createClinicalPromptPool(opts) { }); var parsed = parseJsonObject(String(ai.content || '')); var offered = Array.isArray(parsed.questions) ? parsed.questions.length : 0; - if (!offered) console.warn('[clinical-assistant] prompt pool ' + item.category + ' batch ' + (i + 1) + ': nothing parseable in ' + String(ai.content || '').length + ' chars'); + if (!offered) { + // The model answers a JSON task with an empty message now and then — + // two batches in five came back with nothing. One more try with the + // same snippets is cheap and usually enough. + console.warn('[clinical-assistant] prompt pool ' + item.category + ' batch ' + (i + 1) + ': nothing parseable in ' + String(ai.content || '').length + ' chars (finish ' + (ai.finishReason || '?') + '); retrying once'); + i--; batches++; if (batches > 12) break; + continue; + } else if (parsed.truncated) console.warn('[clinical-assistant] prompt pool ' + item.category + ' batch ' + (i + 1) + ': reply was cut off; ' + offered + ' questions salvaged'); categoryExamples = normalizeGeneratedExamples(categoryExamples.concat(parsed.questions || []), item).slice(0, item.quota); }