diff --git a/apps/memos-local-plugin/core/llm/fetcher.ts b/apps/memos-local-plugin/core/llm/fetcher.ts index 5e8b0317e..53eb55ec3 100644 --- a/apps/memos-local-plugin/core/llm/fetcher.ts +++ b/apps/memos-local-plugin/core/llm/fetcher.ts @@ -61,6 +61,7 @@ export async function httpPostJson(opts: HttpPostOpts): Promise< attempt, transient, durationMs: ms, + body: truncateLogBody(text), }); if (transient && attempt <= opts.maxRetries) { opts.onRetry?.(attempt); @@ -137,6 +138,7 @@ export async function httpPostStream(opts: { provider: LlmProviderName; log: LlmProviderLogger; }): Promise { + const start = Date.now(); const signal = mergeSignals(opts.signal, AbortSignal.timeout(opts.timeoutMs)); const resp = await fetch(opts.url, { method: "POST", @@ -150,6 +152,12 @@ export async function httpPostStream(opts: { }); if (!resp.ok) { const text = await safeText(resp); + opts.log.warn("http.non_ok", { + status: resp.status, + transient: resp.status >= 500 || resp.status === 429, + durationMs: Date.now() - start, + body: truncateLogBody(text), + }); throw new MemosError( errCodeForStatus(resp.status), `HTTP ${resp.status} from ${opts.provider} (stream)`, @@ -217,6 +225,10 @@ async function safeText(resp: Response): Promise { } } +function truncateLogBody(text: string | undefined): string | undefined { + return text?.slice(0, 512); +} + function isTransientError(err: unknown): boolean { if (!(err instanceof Error)) return false; const msg = err.message ?? ""; diff --git a/apps/memos-local-plugin/tests/unit/llm/fetcher.test.ts b/apps/memos-local-plugin/tests/unit/llm/fetcher.test.ts index 3609aa2d8..a3941b7aa 100644 --- a/apps/memos-local-plugin/tests/unit/llm/fetcher.test.ts +++ b/apps/memos-local-plugin/tests/unit/llm/fetcher.test.ts @@ -101,6 +101,31 @@ describe("llm/fetcher", () => { } }); + it("logs a truncated response body for non-ok JSON responses", async () => { + const warn = vi.fn(); + const body = "x".repeat(600); + mockFetch([new Response(body, { status: 400 })]); + + await expect( + httpPostJson({ + url: "https://x", + body: {}, + timeoutMs: 5_000, + maxRetries: 0, + provider: "openai_compatible", + log: { ...nullLog(), warn }, + }), + ).rejects.toBeInstanceOf(MemosError); + + expect(warn).toHaveBeenCalledWith( + "http.non_ok", + expect.objectContaining({ + status: 400, + body: "x".repeat(512), + }), + ); + }); + it("timeout → LLM_TIMEOUT", async () => { const timeout = new DOMException("The operation was aborted due to timeout", "TimeoutError"); mockFetch([timeout]); @@ -172,6 +197,29 @@ describe("llm/fetcher", () => { } }); + it("logs response body for non-ok streaming responses", async () => { + const warn = vi.fn(); + mockFetch([new Response("stream rejected", { status: 400 })]); + + await expect( + httpPostStream({ + url: "https://x", + body: {}, + timeoutMs: 5_000, + provider: "openai_compatible", + log: { ...nullLog(), warn }, + }), + ).rejects.toBeInstanceOf(MemosError); + + expect(warn).toHaveBeenCalledWith( + "http.non_ok", + expect.objectContaining({ + status: 400, + body: "stream rejected", + }), + ); + }); + it("decodeSse splits events at blank lines and drops [DONE] sentinel handling to caller", async () => { const chunks = [ "data: {\"a\":1}\n\n",