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(""); + }); +});