fix(ai-moderation): read delta.reasoning_content in stream aggregation — image vision never returned text
Root cause: 9router combo 'multimodal' routes to cloudflare-ai/@cf/google/
gemma-4-26b-a4b-it which streams ALL output in delta.reasoning_content
(content:"") and finishes with 'length' at max_tokens. llmClient only read
delta.content, so llmVision returned empty → every image moderation fell back
to text-only analysis ('Meskipun analisis gambar gagal' in every ai_analysis).
Fix: extractChunkText() prefers delta.content then falls back to
delta.reasoning_content (also handles message/text/response fields), with
unit tests for the exact 9router chunk shape. Verified live against a real
DB image: oc/mimo-v2.5-free (new first model in the multimodal combo) returns
a proper description in delta.content.
This commit is contained in:
@@ -56,7 +56,7 @@ export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> {
|
||||
*/
|
||||
type LLMResponseChunk = {
|
||||
choices?: Array<{
|
||||
delta?: { content?: string | null };
|
||||
delta?: { content?: string | null; reasoning_content?: string | null };
|
||||
message?: { content?: string | null };
|
||||
finish_reason?: string | null;
|
||||
text?: string;
|
||||
@@ -67,6 +67,29 @@ type LLMResponseChunk = {
|
||||
finish_reason?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the textual payload from a single streaming chunk. Prefers
|
||||
* `delta.content`; falls back to `delta.reasoning_content` (DeepSeek-style /
|
||||
* Cloudflare gemma stream ALL output there with content:"") so reasoning-only
|
||||
* models still produce usable aggregated text. Exported for unit tests.
|
||||
*/
|
||||
export function extractChunkText(
|
||||
chunk: LLMResponseChunk | null | undefined,
|
||||
): string {
|
||||
if (!chunk) return "";
|
||||
const choice = chunk.choices?.[0];
|
||||
return (
|
||||
choice?.delta?.content ||
|
||||
choice?.delta?.reasoning_content ||
|
||||
choice?.message?.content ||
|
||||
choice?.text ||
|
||||
chunk?.message?.content ||
|
||||
chunk?.response ||
|
||||
chunk?.content ||
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy singleton — created on first use so that config is always resolved.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -167,15 +190,7 @@ export async function llmChat(
|
||||
let finishReason = "stop";
|
||||
for await (const chunk of response as unknown as AsyncIterable<LLMResponseChunk>) {
|
||||
const choice = chunk?.choices?.[0];
|
||||
const textChunk =
|
||||
choice?.delta?.content ||
|
||||
choice?.message?.content ||
|
||||
choice?.text ||
|
||||
chunk?.message?.content ||
|
||||
chunk?.response ||
|
||||
chunk?.content ||
|
||||
"";
|
||||
content += textChunk;
|
||||
content += extractChunkText(chunk);
|
||||
const fr = choice?.finish_reason || chunk?.finish_reason;
|
||||
if (fr) finishReason = fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user