Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions apps/memos-local-plugin/core/llm/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,41 @@ export function createLlmClientWithProvider(
attempt++;
const { completion } = await callWithFallback(msgs, call, opts, op);
lastRaw = completion.text;

// Detect output truncation before attempting JSON parsing. When the
// provider signals `finishReason === "length"` the answer is incomplete
// by definition; parsing will fail (or worse: succeed on a nested sub-
// block), causing the caller's schema validator to report a field-level
// error that points the debugger at the model instead of the real cause.
// Surface a dedicated error immediately so the log clearly shows the
// truncation, including rawLen for budget diagnosis.
if (completion.finishReason === "length") {
const truncErr = new MemosError(
ERROR_CODES.LLM_OUTPUT_MALFORMED,
"LLM output truncated at maxTokens (finishReason=length); increase maxTokens or reduce prompt size",
{
provider: provider.name,
op,
finishReason: "length",
rawLen: completion.text.length,
rawPreview: completion.text.slice(0, 512),
},
);
lastErr = truncErr;
jsonLog.warn("malformed", {
op,
attempt,
finishReason: "length",
rawLen: completion.text.length,
err: summarizeErr(truncErr),
});
if (attempt <= maxMalformedRetries) {
retries++;
continue;
}
break;
}

try {
const parsed = opts.parse
? opts.parse(completion.text)
Expand All @@ -537,6 +572,7 @@ export function createLlmClientWithProvider(
jsonLog.warn("malformed", {
op,
attempt,
rawLen: completion.text.length,
err: summarizeErr(err),
});
if (attempt <= maxMalformedRetries) {
Expand Down
10 changes: 8 additions & 2 deletions apps/memos-local-plugin/core/llm/json-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,20 @@ function stripFences(s: string): string {
* Find the first balanced `{…}` or `[…]` block. Returns null when nothing
* obvious is found. Naive — but good enough for LLMs that say "Here you go:
* {…}" or "I'll return [ …, … ] now."
*
* Stops at the first opener and returns whatever `walkToClose` finds —
* including null when that block is unbalanced (truncated). We deliberately
* do NOT continue scanning for a nested balanced sub-block: silently returning
* a nested value (e.g. a `domain_tags` array from inside a truncated root
* object) would cause the caller's schema validator to report a misleading
* field-level error instead of the real cause (output truncated at maxTokens).
*/
function extractFirstJsonBlock(s: string): string | null {
const openers = ["{", "["];
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (!openers.includes(ch!)) continue;
const match = walkToClose(s, i);
if (match) return match;
return walkToClose(s, i);
}
return null;
}
Expand Down
57 changes: 57 additions & 0 deletions apps/memos-local-plugin/tests/unit/llm/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,16 @@ describe("llm/client", () => {
expect(s.circuitOpenedReason).toBeNull();
});

it("LlmClientStats exposes circuit fields when closed", async () => {
const fake = new FakeProvider("openai_compatible", () => ({ text: "ok", durationMs: 1 }));
const client = createLlmClientWithProvider(cfg(), fake);
await client.complete("x");
const s = client.stats();
expect(s.circuitOpen).toBe(false);
expect(s.circuitOpenUntil).toBeNull();
expect(s.circuitOpenedReason).toBeNull();
});

it("re-opens the breaker if the half-open probe fails terminally again", async () => {
const sink = statusSink();
let now = 1_000_000;
Expand All @@ -571,4 +581,51 @@ describe("llm/client", () => {
expect(provider.calls).toBe(2);
});
});

it("completeJson throws LLM_OUTPUT_MALFORMED with finishReason=length detail when provider returns finishReason=length", async () => {
const fake = new FakeProvider("openai_compatible", () => ({
text: '{"title":"x","domain_tags":["a","b"]',
finishReason: "length",
durationMs: 1,
}));
const client = createLlmClientWithProvider(cfg(), fake);
try {
await client.completeJson("ask");
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(MemosError);
expect((err as MemosError).code).toBe(ERROR_CODES.LLM_OUTPUT_MALFORMED);
expect((err as MemosError).details?.finishReason).toBe("length");
expect((err as MemosError).details?.rawLen).toBeGreaterThan(0);
}
});

it("completeJson includes rawLen in malformed error when truncated", async () => {
const rawText = '{"title":"x","domain_tags":["a","b"]';
const fake = new FakeProvider("openai_compatible", () => ({
text: rawText,
finishReason: "length",
durationMs: 1,
}));
const client = createLlmClientWithProvider(cfg(), fake);
try {
await client.completeJson("ask");
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(MemosError);
expect((err as MemosError).details?.rawLen).toBe(rawText.length);
}
});

it("completeJson succeeds normally when finishReason=stop and JSON is valid", async () => {
const fake = new FakeProvider("openai_compatible", () => ({
text: '{"title":"Good","items":[1,2,3]}',
finishReason: "stop",
durationMs: 1,
}));
const client = createLlmClientWithProvider(cfg(), fake);
const r = await client.completeJson<{ title: string; items: number[] }>("ask");
expect(r.value.title).toBe("Good");
expect(r.value.items).toEqual([1, 2, 3]);
});
});
22 changes: 22 additions & 0 deletions apps/memos-local-plugin/tests/unit/llm/json-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,26 @@ describe("llm/json-mode", () => {
expect(h).toMatch(/Expected shape/);
expect(h).toMatch(/"a"/);
});

it("extractFirstJsonBlock stops at the first opener and returns null for truncated root objects", () => {
const raw = '{"title":"Alpine dependency resolution","domain_tags":["network","http"],"environment":[{"label":"Foo","description":"Bar truncated here';
try {
parseLlmJson(raw);
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(MemosError);
expect((err as MemosError).code).toBe("llm_output_malformed");
}
});

it("does not silently return a nested balanced block when outer object is truncated", () => {
const raw = '{"title":"x","items":[1,2,3],"extra":"trunc';
try {
parseLlmJson(raw);
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(MemosError);
expect((err as MemosError).code).toBe("llm_output_malformed");
}
});
});
Loading