From 50371bd2d1d4b875b51a80f919f3c4b66d9eb72f Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 11 Aug 2026 08:06:28 +0700 Subject: [PATCH] =?UTF-8?q?fix(ai-moderation):=20read=20delta.reasoning=5F?= =?UTF-8?q?content=20in=20stream=20aggregation=20=E2=80=94=20image=20visio?= =?UTF-8?q?n=20never=20returned=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/modules/ai-moderation/llmClient.ts | 35 +++++++--- .../tests/llmChunkExtraction.test.ts | 70 +++++++++++++++++++ 2 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 services/discord-gateway/tests/llmChunkExtraction.test.ts diff --git a/services/discord-gateway/src/modules/ai-moderation/llmClient.ts b/services/discord-gateway/src/modules/ai-moderation/llmClient.ts index 230b4a5..10d906b 100644 --- a/services/discord-gateway/src/modules/ai-moderation/llmClient.ts +++ b/services/discord-gateway/src/modules/ai-moderation/llmClient.ts @@ -56,7 +56,7 @@ export async function withLlmConcurrency(fn: () => Promise): Promise { */ 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) { 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; } diff --git a/services/discord-gateway/tests/llmChunkExtraction.test.ts b/services/discord-gateway/tests/llmChunkExtraction.test.ts new file mode 100644 index 0000000..35e0e7a --- /dev/null +++ b/services/discord-gateway/tests/llmChunkExtraction.test.ts @@ -0,0 +1,70 @@ +// ═══════════════════════════════════════════════════════════════════════════ +// llmClient chunk extraction — reasoning_content fallback (pure, no network) +// ═══════════════════════════════════════════════════════════════════════════ +// Regression: 9router "multimodal" combo routed to cloudflare gemma-4-26b +// which streams ALL output in delta.reasoning_content with content:"" — the +// old extractor returned empty text → llmVision reported "Vision API null +// response" → every image moderation batch fell back to text-only analysis +// (LLM kept writing "Meskipun analisis gambar gagal"). +import { describe, expect, it } from "vitest"; +import { extractChunkText } from "../src/modules/ai-moderation/llmClient.js"; + +describe("extractChunkText — streaming chunk text extraction", () => { + it("reads delta.content (standard OpenAI streaming)", () => { + expect( + extractChunkText({ + choices: [{ delta: { content: "halo" }, finish_reason: null }], + }), + ).toBe("halo"); + }); + + it("falls back to delta.reasoning_content when content is empty — reasoning-only models (cloudflare gemma)", () => { + // Exact shape seen from 9router → cloudflare-ai/@cf/google/gemma-4-26b: + // {"choices":[{"delta":{"content":"","reasoning_content":"Task","role":"assistant"},"finish_reason":null,...}]} + expect( + extractChunkText({ + choices: [ + { + delta: { content: "", reasoning_content: "Task" }, + finish_reason: null, + }, + ], + }), + ).toBe("Task"); + }); + + it("prefers content over reasoning when both present (deepseek-style final answer)", () => { + expect( + extractChunkText({ + choices: [ + { + delta: { content: "jawaban akhir", reasoning_content: "pikiran" }, + finish_reason: null, + }, + ], + }), + ).toBe("jawaban akhir"); + }); + + it("handles Anthropic-style message.content", () => { + expect(extractChunkText({ message: { content: "via message" } })).toBe( + "via message", + ); + }); + + it("handles top-level content / response fields (local LLM proxies)", () => { + expect(extractChunkText({ content: "top-level" })).toBe("top-level"); + expect(extractChunkText({ response: "via response" })).toBe("via response"); + }); + + it("returns empty string for null/undefined/empty chunks", () => { + expect(extractChunkText(null)).toBe(""); + expect(extractChunkText(undefined)).toBe(""); + expect(extractChunkText({})).toBe(""); + expect( + extractChunkText({ + choices: [{ delta: { content: "", reasoning_content: null } }], + }), + ).toBe(""); + }); +});